Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f004ea47 | ||
|
|
f24c98102c | ||
|
|
31a9f24d66 | ||
|
|
29f2ff707e | ||
|
|
318a095c15 | ||
|
|
1796829a77 | ||
|
|
96e6522a72 | ||
|
|
cde13864a3 | ||
|
|
ea5beec889 | ||
|
|
b75152d4ad | ||
|
|
970644493e | ||
|
|
80996e3e82 | ||
|
|
9584f806ee | ||
|
|
e2f420df0c | ||
|
|
11faba74ea | ||
|
|
6459f8bf87 | ||
|
|
cd1ab6e9f7 | ||
|
|
8e5bfa3657 | ||
|
|
01ea21917a | ||
|
|
236698f1b4 | ||
|
|
0699523914 | ||
|
|
9ebd49d7b3 | ||
|
|
ab734ac996 | ||
|
|
88fe49d506 | ||
|
|
b497a44930 | ||
|
|
1324b23167 | ||
|
|
c9eb3fb7ec | ||
|
|
84bfcf7e06 | ||
|
|
29167dcdf6 | ||
|
|
01aca2e27c | ||
|
|
26fcd90557 | ||
|
|
129a1c8b28 | ||
|
|
03f96537eb | ||
|
|
405816939d | ||
|
|
6661375f99 | ||
|
|
d6e759f1cc | ||
|
|
8a38516715 | ||
|
|
aaeb2109f6 | ||
|
|
f65e76e858 | ||
|
|
252395d164 | ||
|
|
f36d108878 | ||
|
|
8e548daf0c | ||
|
|
b15f10ade9 | ||
|
|
b9c7bc0e2a | ||
|
|
aa74fe96da | ||
|
|
3c296e743d | ||
|
|
79cadff853 | ||
|
|
19b653a05c | ||
|
|
50e567c411 | ||
|
|
26e2c12f8c | ||
|
|
de8797a48d | ||
|
|
e1acdb27b3 | ||
|
|
dbf066c692 | ||
|
|
f5445db8d4 | ||
|
|
038df11112 | ||
|
|
63d857ca2c | ||
|
|
c382822f72 | ||
|
|
1d37502f44 | ||
|
|
8396078f94 | ||
|
|
b051e5af0c | ||
|
|
f0bce58cfa | ||
|
|
c563b1ffaa | ||
|
|
8876a8ba83 | ||
|
|
6e5ab73e8e | ||
|
|
206ab7bff0 | ||
|
|
8d07752a03 | ||
|
|
0843b8f9ce | ||
|
|
5d8d7e4713 | ||
|
|
5320ec631a | ||
|
|
c7aea782ae | ||
|
|
08566d2b4d | ||
|
|
177d9389c5 | ||
|
|
fc20309642 | ||
|
|
07cb1f7f95 | ||
|
|
69bf28882d | ||
|
|
fba7091997 | ||
|
|
cdc6c5b11b | ||
|
|
3edb6af5a9 | ||
|
|
cded364f1b |
@@ -35,6 +35,16 @@ jobs:
|
|||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
cache: true
|
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
|
- name: Get dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -65,7 +75,12 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
run: flutter build apk --release
|
run: |
|
||||||
|
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
cp android/init.gradle ~/.gradle/init.gradle
|
||||||
|
|
||||||
|
flutter build apk --release
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Player System Animation & Polish Audit
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This is a Music Assistant client app (Ensemble) built with Flutter. The player system has undergone extensive development and now needs a comprehensive polish audit. The goal is buttery smooth 60fps animations and a premium user experience.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The player system consists of three interconnected components:
|
||||||
|
|
||||||
|
1. **Mini Player** (collapsed state) - Floating card at bottom, shows current track
|
||||||
|
2. **Expanded Player** - Full-screen player with album art, controls, progress
|
||||||
|
3. **Queue Panel** - Slides in from right of expanded player, shows playback queue
|
||||||
|
|
||||||
|
Additionally:
|
||||||
|
- **Player Selector** - Horizontal swipe between multiple players (collapsed mode)
|
||||||
|
- **Device Reveal** - Swipe down from mini player to show available players
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/widgets/
|
||||||
|
├── expandable_player.dart # Main player widget (1800+ lines)
|
||||||
|
├── global_player_overlay.dart # Overlay wrapper, static accessors
|
||||||
|
├── player/
|
||||||
|
│ ├── queue_panel.dart # Queue list with great_list_view
|
||||||
|
│ ├── chapters_panel.dart # Audiobook chapters
|
||||||
|
│ └── mini_player_content.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## CRITICAL CONSTRAINTS
|
||||||
|
|
||||||
|
1. **DO NOT MODIFY the queue list implementation** - It uses `great_list_view` package with `AutomaticAnimatedListView`. This was chosen specifically to avoid Flutter's grey screen overlay bug (#103804). The current implementation works correctly.
|
||||||
|
|
||||||
|
2. **Preserve all existing functionality** - Focus on polish, not rewrites
|
||||||
|
|
||||||
|
3. **Test changes incrementally** - This system is complex with many interdependencies
|
||||||
|
|
||||||
|
## Audit Areas
|
||||||
|
|
||||||
|
### 1. Animation Performance Audit
|
||||||
|
|
||||||
|
Use sub-agents to analyze each animation system:
|
||||||
|
|
||||||
|
**Agent 1: Expand/Collapse Animation**
|
||||||
|
- Analyze `_controller` and `_expandAnimation`
|
||||||
|
- Check for jank during morphing transition
|
||||||
|
- Verify lerp functions are efficient
|
||||||
|
- Look for unnecessary rebuilds during animation
|
||||||
|
- Check `RepaintBoundary` placement
|
||||||
|
|
||||||
|
**Agent 2: Queue Panel Animation**
|
||||||
|
- Analyze `_queuePanelController` and `_queuePanelAnimation`
|
||||||
|
- Verify slide-in/out is smooth
|
||||||
|
- Check interaction with expanded player state
|
||||||
|
- Verify the Listener-based swipe detection doesn't cause issues
|
||||||
|
|
||||||
|
**Agent 3: Player Selector Animation**
|
||||||
|
- Analyze horizontal swipe between players
|
||||||
|
- Check `_slideOffset` and peek player animations
|
||||||
|
- Verify finger-following feels natural
|
||||||
|
- Look for edge cases with rapid swipes
|
||||||
|
|
||||||
|
**Agent 4: Micro-interactions**
|
||||||
|
- Play/pause button animations
|
||||||
|
- Progress bar interactions
|
||||||
|
- Volume swipe overlay
|
||||||
|
- Favorite button feedback
|
||||||
|
- Skip button responsiveness
|
||||||
|
|
||||||
|
### 2. State Management Audit
|
||||||
|
|
||||||
|
**Check for:**
|
||||||
|
- Unnecessary `setState` calls during animations
|
||||||
|
- ValueNotifier usage efficiency
|
||||||
|
- Animation listener cleanup
|
||||||
|
- Memory leaks from subscriptions
|
||||||
|
- State synchronization between components
|
||||||
|
|
||||||
|
### 3. Visual Polish Audit
|
||||||
|
|
||||||
|
**Check for:**
|
||||||
|
- Color transitions during expand/collapse
|
||||||
|
- Text fade/scale animations
|
||||||
|
- Image loading placeholders
|
||||||
|
- Shadow/elevation consistency
|
||||||
|
- Border radius morphing smoothness
|
||||||
|
- Safe area handling
|
||||||
|
|
||||||
|
### 4. Gesture Conflict Resolution
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- Vertical drag (expand/collapse) vs horizontal drag (player switch)
|
||||||
|
- Queue panel swipe-right-to-close vs Dismissible swipe-left-to-delete
|
||||||
|
- Edge dead zones for Android back gesture
|
||||||
|
- Tap vs drag disambiguation
|
||||||
|
|
||||||
|
### 5. Edge Cases
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Rapid expand/collapse
|
||||||
|
- Swipe during animation
|
||||||
|
- Back button during animations
|
||||||
|
- Track change during queue scroll
|
||||||
|
- Network image loading failures
|
||||||
|
- Very long track/artist names
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
```
|
||||||
|
ultrathink: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 1: Discovery (Read-Only)
|
||||||
|
Launch parallel sub-agents to analyze each file:
|
||||||
|
- Read and understand the animation architecture
|
||||||
|
- Map all animation controllers and their relationships
|
||||||
|
- Identify potential performance bottlenecks
|
||||||
|
- Document current RepaintBoundary usage
|
||||||
|
|
||||||
|
### Phase 2: Analysis Report
|
||||||
|
Compile findings into categories:
|
||||||
|
- **Critical**: Causes visible jank or bugs
|
||||||
|
- **Important**: Noticeable but not breaking
|
||||||
|
- **Nice-to-have**: Minor polish improvements
|
||||||
|
|
||||||
|
### Phase 3: Implementation Plan
|
||||||
|
For each finding:
|
||||||
|
- Specific file and line numbers
|
||||||
|
- Proposed change
|
||||||
|
- Risk assessment
|
||||||
|
- Testing approach
|
||||||
|
|
||||||
|
### Phase 4: Incremental Fixes
|
||||||
|
- One fix at a time
|
||||||
|
- Build and test after each change
|
||||||
|
- Verify no regressions
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Provide a structured audit report:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Animation Audit Report
|
||||||
|
|
||||||
|
### Executive Summary
|
||||||
|
[2-3 sentences on overall state]
|
||||||
|
|
||||||
|
### Critical Issues
|
||||||
|
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||||
|
|
||||||
|
### Important Issues
|
||||||
|
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||||
|
|
||||||
|
### Polish Opportunities
|
||||||
|
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||||
|
|
||||||
|
### Architecture Observations
|
||||||
|
[Notes on overall structure, patterns noticed]
|
||||||
|
|
||||||
|
### Recommended Priority Order
|
||||||
|
1. [First fix]
|
||||||
|
2. [Second fix]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
After this audit and fixes:
|
||||||
|
- All animations run at consistent 60fps
|
||||||
|
- No jank during any transition
|
||||||
|
- Gestures feel responsive and natural
|
||||||
|
- State transitions are seamless
|
||||||
|
- No visual glitches or flickers
|
||||||
|
- Memory usage is stable during interactions
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The app uses adaptive theming (colors extracted from album art)
|
||||||
|
- There's an `AnimationDebugger` utility for profiling
|
||||||
|
- Position tracking uses a shared `PositionTracker` singleton
|
||||||
|
- The player supports both music and audiobooks (different UI modes)
|
||||||
+119
@@ -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
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
# Ensemble Flutter App - Code Audit Report
|
||||||
|
|
||||||
|
**Date:** January 4, 2026
|
||||||
|
**Branch:** `audit/code-quality-review`
|
||||||
|
**Files Audited:** 93 Dart files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
|
||||||
|
|
||||||
|
| Category | Issues Found | Priority |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| Deprecated APIs | 20 instances | HIGH |
|
||||||
|
| Missing const constructors | 100+ instances | MEDIUM |
|
||||||
|
| Image cache parameters | 7 locations | MEDIUM |
|
||||||
|
| Code duplication | 8 view mode functions | LOW |
|
||||||
|
| Color constants | 3 files with duplicates | LOW |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CRITICAL: Deprecated API Usage
|
||||||
|
|
||||||
|
### `colorScheme.background` (20 instances)
|
||||||
|
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- lib/main.dart:335, 336
|
||||||
|
- lib/screens/library_artists_screen.dart:21
|
||||||
|
- lib/screens/new_home_screen.dart:121
|
||||||
|
- lib/screens/artist_details_screen.dart:708, 720
|
||||||
|
- lib/screens/library_playlists_screen.dart:53
|
||||||
|
- lib/screens/library_tracks_screen.dart:19
|
||||||
|
- lib/screens/login_screen.dart:489
|
||||||
|
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
|
||||||
|
- lib/screens/album_details_screen.dart:807, 819
|
||||||
|
- lib/screens/library_albums_screen.dart:19
|
||||||
|
- lib/screens/search_screen.dart:427, 738, 739
|
||||||
|
- lib/screens/settings_screen.dart:245
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. HIGH: Missing const Constructors
|
||||||
|
|
||||||
|
### EdgeInsets.zero (64 instances)
|
||||||
|
High-impact optimization - used extensively in button padding.
|
||||||
|
|
||||||
|
**Key files:**
|
||||||
|
- lib/screens/album_details_screen.dart (12 instances)
|
||||||
|
- lib/screens/artist_details_screen.dart (8 instances)
|
||||||
|
- lib/screens/audiobook_detail_screen.dart (3 instances)
|
||||||
|
- lib/screens/audiobook_series_screen.dart (2 instances)
|
||||||
|
- lib/screens/audiobook_author_screen.dart (2 instances)
|
||||||
|
|
||||||
|
### BorderRadius.circular (40+ instances)
|
||||||
|
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
|
||||||
|
|
||||||
|
### CachedNetworkImage without memCacheWidth/Height
|
||||||
|
|
||||||
|
**lib/screens/new_library_screen.dart:**
|
||||||
|
- Line 1395-1402: Author image (48x48) - missing cache params
|
||||||
|
- Line 1480-1486: Author card image - missing cache params
|
||||||
|
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
|
||||||
|
- Line 1740-1745: Audiobook grid cover - missing cache params
|
||||||
|
- Line 1959-1969: Playlist cover (56x56) - missing cache params
|
||||||
|
- Line 2143-2152: Playlist grid covers - missing cache params
|
||||||
|
- Line 2973-2981: Album cover (56x56) - missing cache params
|
||||||
|
|
||||||
|
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. LOW: Code Duplication
|
||||||
|
|
||||||
|
### View Mode Cycling Functions (8 nearly identical functions)
|
||||||
|
**File:** lib/screens/new_library_screen.dart
|
||||||
|
|
||||||
|
Functions that follow identical switch/case pattern:
|
||||||
|
- `_cycleArtistsViewMode()` (line 184)
|
||||||
|
- `_cycleAlbumsViewMode()` (line 200)
|
||||||
|
- `_cyclePlaylistsViewMode()` (line 216)
|
||||||
|
- `_cycleAuthorsViewMode()` (line 232)
|
||||||
|
- `_cycleAudiobooksViewMode()` (line 248)
|
||||||
|
- `_cycleSeriesViewMode()` (line 286)
|
||||||
|
- `_cycleRadioViewMode()` (line 303)
|
||||||
|
- `_cyclePodcastsViewMode()` (line 319)
|
||||||
|
|
||||||
|
**Recommendation:** Create generic utility function.
|
||||||
|
|
||||||
|
### Duplicated Color Constants (3 files)
|
||||||
|
Series fallback colors defined identically in:
|
||||||
|
- lib/widgets/series_row.dart:246-253
|
||||||
|
- lib/screens/audiobook_series_screen.dart:466-473
|
||||||
|
- lib/screens/new_library_screen.dart:2112-2119
|
||||||
|
|
||||||
|
**Recommendation:** Extract to lib/constants/colors.dart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. POSITIVE FINDINGS (Already Optimized)
|
||||||
|
|
||||||
|
- **RepaintBoundary**: Properly implemented in all list item widgets
|
||||||
|
- **ListView.builder**: Used consistently with cacheExtent: 1000
|
||||||
|
- **ScrollController disposal**: All 8+ controllers properly disposed
|
||||||
|
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
|
||||||
|
- **Debounced operations**: Color extraction debounced to 300ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Action Plan
|
||||||
|
|
||||||
|
### Phase 1: Quick Wins (Low Risk)
|
||||||
|
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
|
||||||
|
2. Add `const` to EdgeInsets.zero instances
|
||||||
|
|
||||||
|
### Phase 2: Image Optimization
|
||||||
|
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
|
||||||
|
|
||||||
|
### Phase 3: Code Cleanup (Optional)
|
||||||
|
1. Extract view mode cycling to utility function
|
||||||
|
2. Centralize color constants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files by Complexity
|
||||||
|
|
||||||
|
| File | Lines | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
|
||||||
|
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
|
||||||
|
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Audit Summary
|
||||||
|
|
||||||
|
**Branch:** `audit/code-quality-review`
|
||||||
|
**Date:** January 4, 2026
|
||||||
|
|
||||||
|
## Changes Applied
|
||||||
|
|
||||||
|
### Phase 1: Analysis Complete
|
||||||
|
- Created `AUDIT_REPORT.md` with comprehensive findings
|
||||||
|
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
|
||||||
|
|
||||||
|
### Phase 2: Deprecated API Fixes (18 files)
|
||||||
|
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- lib/screens/library_artists_screen.dart
|
||||||
|
- lib/screens/library_albums_screen.dart
|
||||||
|
- lib/screens/library_playlists_screen.dart
|
||||||
|
- lib/screens/library_tracks_screen.dart
|
||||||
|
- lib/screens/artist_details_screen.dart
|
||||||
|
- lib/screens/new_home_screen.dart
|
||||||
|
- lib/screens/login_screen.dart
|
||||||
|
- lib/screens/settings_screen.dart
|
||||||
|
- lib/screens/album_details_screen.dart
|
||||||
|
- lib/screens/search_screen.dart
|
||||||
|
- lib/screens/new_library_screen.dart
|
||||||
|
|
||||||
|
### Phase 3: Scroll Performance Optimization
|
||||||
|
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
|
||||||
|
|
||||||
|
**lib/screens/new_library_screen.dart:**
|
||||||
|
- Line 1400-1401: Author list tile image (128x128 cache)
|
||||||
|
- Line 1487-1488: Author grid card image (256x256 cache)
|
||||||
|
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
|
||||||
|
- Line 1749-1750: Audiobook grid cover (512x512 cache)
|
||||||
|
- Line 1970-1971: Series list tile cover (256x256 cache)
|
||||||
|
- Line 2986-2987: Album list tile cover (256x256 cache)
|
||||||
|
|
||||||
|
## Recommendations NOT Implemented (Low Priority)
|
||||||
|
|
||||||
|
These were identified but NOT changed to preserve functionality:
|
||||||
|
|
||||||
|
1. **View mode cycling refactor** - 8 similar functions could be consolidated
|
||||||
|
2. **Color constant extraction** - Duplicated colors in 3 files
|
||||||
|
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
|
||||||
|
4. **BorderRadius.circular const** - 40+ instances
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Changes should be verified by:
|
||||||
|
1. GitHub Actions build (triggered automatically)
|
||||||
|
2. Manual UI testing of scroll smoothness
|
||||||
|
3. Verify no visual regressions in dark/light themes
|
||||||
|
|
||||||
|
## Build Trigger
|
||||||
|
|
||||||
|
GitHub Actions workflow triggered on branch `audit/code-quality-review`
|
||||||
@@ -2,6 +2,9 @@ buildscript {
|
|||||||
ext.kotlin_version = '1.9.10'
|
ext.kotlin_version = '1.9.10'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
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()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +17,9 @@ buildscript {
|
|||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
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()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Force all repositories to use mirrors first
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
all { ArtifactRepository repo ->
|
||||||
|
if (repo instanceof MavenArtifactRepository) {
|
||||||
|
def url = repo.url.toString()
|
||||||
|
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||||
|
remove repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add mirrors before Maven Central
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsEvaluated { settings ->
|
||||||
|
settings.pluginManagement {
|
||||||
|
repositories {
|
||||||
|
all { ArtifactRepository repo ->
|
||||||
|
if (repo instanceof MavenArtifactRepository) {
|
||||||
|
def url = repo.url.toString()
|
||||||
|
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||||
|
remove repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,4 +28,16 @@ class HeroTags {
|
|||||||
|
|
||||||
/// Podcast cover hero tag prefix
|
/// Podcast cover hero tag prefix
|
||||||
static const String podcastCover = 'podcast_cover_';
|
static const String podcastCover = 'podcast_cover_';
|
||||||
|
|
||||||
|
/// Playlist cover hero tag prefix
|
||||||
|
static const String playlistCover = 'playlist_cover_';
|
||||||
|
|
||||||
|
/// Playlist title hero tag prefix
|
||||||
|
static const String playlistTitle = 'playlist_title_';
|
||||||
|
|
||||||
|
/// Radio station cover hero tag prefix
|
||||||
|
static const String radioCover = 'radio_cover_';
|
||||||
|
|
||||||
|
/// Radio station title hero tag prefix
|
||||||
|
static const String radioTitle = 'radio_title_';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,16 @@
|
|||||||
"description": "Home screen section title"
|
"description": "Home screen section title"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"favoritePlaylists": "Favorite Playlists",
|
||||||
|
"@favoritePlaylists": {
|
||||||
|
"description": "Home screen section title"
|
||||||
|
},
|
||||||
|
|
||||||
|
"favoriteRadioStations": "Favorite Radio Stations",
|
||||||
|
"@favoriteRadioStations": {
|
||||||
|
"description": "Home screen section title"
|
||||||
|
},
|
||||||
|
|
||||||
"searchMusic": "Search music...",
|
"searchMusic": "Search music...",
|
||||||
"@searchMusic": {
|
"@searchMusic": {
|
||||||
"description": "Search placeholder text"
|
"description": "Search placeholder text"
|
||||||
@@ -1062,10 +1072,15 @@
|
|||||||
"showFavoriteAlbums": "Show a row of your favorite albums",
|
"showFavoriteAlbums": "Show a row of your favorite albums",
|
||||||
"showFavoriteArtists": "Show a row of your favorite artists",
|
"showFavoriteArtists": "Show a row of your favorite artists",
|
||||||
"showFavoriteTracks": "Show a row of your favorite tracks",
|
"showFavoriteTracks": "Show a row of your favorite tracks",
|
||||||
|
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
||||||
|
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
|
||||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||||
"addedToFavorites": "Added to favorites",
|
"addedToFavorites": "Added to favorites",
|
||||||
"removedFromFavorites": "Removed from favorites",
|
"removedFromFavorites": "Removed from favorites",
|
||||||
|
"addedToLibrary": "Added to library",
|
||||||
|
"removedFromLibrary": "Removed from library",
|
||||||
|
"addToLibrary": "Add to library",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"noUpcomingTracks": "No upcoming tracks",
|
"noUpcomingTracks": "No upcoming tracks",
|
||||||
"showAll": "Show all",
|
"showAll": "Show all",
|
||||||
@@ -1355,5 +1370,25 @@
|
|||||||
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
|
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
|
||||||
"@dismissPlayerHint": {
|
"@dismissPlayerHint": {
|
||||||
"description": "Hint explaining how to dismiss the player selector"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -71,13 +71,16 @@ Future<void> main() async {
|
|||||||
DeviceOrientation.portraitDown,
|
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(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
statusBarIconBrightness: Brightness.light,
|
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
systemNavigationBarColor: Color(0xFF1a1a1a),
|
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||||
systemNavigationBarIconBrightness: Brightness.light,
|
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -229,10 +232,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
ColorScheme? darkColorScheme;
|
ColorScheme? darkColorScheme;
|
||||||
|
|
||||||
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
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!;
|
final (light, dark) = snapshot.data!;
|
||||||
lightColorScheme = light;
|
lightColorScheme = light.copyWith(
|
||||||
darkColorScheme = dark;
|
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 {
|
} else {
|
||||||
// Use custom color from theme provider
|
// Use custom color from theme provider
|
||||||
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||||
@@ -332,8 +342,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
|||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
systemNavigationBarColor: isDark
|
systemNavigationBarColor: isDark
|
||||||
? widget.darkColorScheme.background
|
? widget.darkColorScheme.surface
|
||||||
: widget.lightColorScheme.background,
|
: widget.lightColorScheme.surface,
|
||||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../services/music_assistant_api.dart';
|
|||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/error_handler.dart';
|
import '../services/error_handler.dart';
|
||||||
import '../services/cache_service.dart';
|
import '../services/cache_service.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
/// Provider for managing library data (artists, albums, tracks, playlists)
|
/// Provider for managing library data (artists, albums, tracks, playlists)
|
||||||
@@ -138,8 +139,12 @@ class LibraryProvider with ChangeNotifier {
|
|||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||||
|
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||||
|
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||||
|
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
|
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||||
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
||||||
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
||||||
]);
|
]);
|
||||||
@@ -166,10 +171,14 @@ class LibraryProvider with ChangeNotifier {
|
|||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||||
|
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||||
|
|
||||||
_artists = await _api!.getArtists(
|
_artists = await _api!.getArtists(
|
||||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
search: search,
|
search: search,
|
||||||
|
albumArtistsOnly: showOnlyArtistsWithAlbums,
|
||||||
);
|
);
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
List<Track> _tracks = [];
|
List<Track> _tracks = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isFavorite = false;
|
bool _isFavorite = false;
|
||||||
|
bool _isInLibrary = false;
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
int? _expandedTrackIndex;
|
int? _expandedTrackIndex;
|
||||||
@@ -52,6 +53,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.album.favorite ?? false;
|
_isFavorite = widget.album.favorite ?? false;
|
||||||
|
_isInLibrary = widget.album.inLibrary;
|
||||||
_loadTracks();
|
_loadTracks();
|
||||||
_loadAlbumDescription();
|
_loadAlbumDescription();
|
||||||
|
|
||||||
@@ -140,7 +142,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
orElse: () => widget.album.providerMappings!.first,
|
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;
|
actualItemId = mapping.itemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +174,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
throw Exception('Could not determine library ID for this album');
|
throw Exception('Could not determine library ID for this album');
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
@@ -214,6 +216,104 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle library status
|
||||||
|
Future<void> _toggleLibrary() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newState = !_isInLibrary;
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
if (newState) {
|
||||||
|
// Add to library - MUST use non-library provider
|
||||||
|
String? actualProvider;
|
||||||
|
String? actualItemId;
|
||||||
|
|
||||||
|
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
|
||||||
|
// For adding to library, we MUST use a non-library provider
|
||||||
|
final nonLibraryMapping = widget.album.providerMappings!.where(
|
||||||
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
||||||
|
).firstOrNull;
|
||||||
|
|
||||||
|
if (nonLibraryMapping != null) {
|
||||||
|
actualProvider = nonLibraryMapping.providerDomain;
|
||||||
|
actualItemId = nonLibraryMapping.itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to item's own provider if no non-library mapping found
|
||||||
|
if (actualProvider == null || actualItemId == null) {
|
||||||
|
if (widget.album.provider != 'library') {
|
||||||
|
actualProvider = widget.album.provider;
|
||||||
|
actualItemId = widget.album.itemId;
|
||||||
|
} else {
|
||||||
|
// Item is library-only, can't add
|
||||||
|
_logger.log('Cannot add to library: album is library-only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
|
||||||
|
success = await maProvider.addToLibrary(
|
||||||
|
mediaType: 'album',
|
||||||
|
provider: actualProvider,
|
||||||
|
itemId: actualItemId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Remove from library
|
||||||
|
int? libraryItemId;
|
||||||
|
if (widget.album.provider == 'library') {
|
||||||
|
libraryItemId = int.tryParse(widget.album.itemId);
|
||||||
|
} else if (widget.album.providerMappings != null) {
|
||||||
|
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
||||||
|
(m) => m.providerInstance == 'library',
|
||||||
|
orElse: () => widget.album.providerMappings!.first,
|
||||||
|
);
|
||||||
|
if (libraryMapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItemId == null) {
|
||||||
|
_logger.log('Cannot remove from library: no library ID found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = await maProvider.removeFromLibrary(
|
||||||
|
mediaType: 'album',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setState(() {
|
||||||
|
_isInLibrary = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error toggling album library: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update library: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _toggleTrackFavorite(int trackIndex) async {
|
Future<void> _toggleTrackFavorite(int trackIndex) async {
|
||||||
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
|
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
|
||||||
|
|
||||||
@@ -260,7 +360,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
orElse: () => track.providerMappings!.first,
|
orElse: () => track.providerMappings!.first,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
actualProvider = mapping.providerInstance;
|
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
|
||||||
|
actualProvider = mapping.providerDomain;
|
||||||
actualItemId = mapping.itemId;
|
actualItemId = mapping.itemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,35 +832,50 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _showFullscreenArt(imageUrl),
|
onTap: () => _showFullscreenArt(imageUrl),
|
||||||
child: Hero(
|
// Shadow container (outside Hero for correct clipping)
|
||||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
child: Container(
|
||||||
child: Container(
|
decoration: BoxDecoration(
|
||||||
width: coverSize,
|
borderRadius: BorderRadius.circular(12),
|
||||||
height: coverSize,
|
boxShadow: [
|
||||||
decoration: BoxDecoration(
|
BoxShadow(
|
||||||
color: colorScheme.surfaceVariant,
|
color: Colors.black.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(16),
|
blurRadius: 20,
|
||||||
boxShadow: [
|
offset: const Offset(0, 10),
|
||||||
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
|
||||||
image: imageUrl != null
|
child: ClipRRect(
|
||||||
? DecorationImage(
|
borderRadius: BorderRadius.circular(12),
|
||||||
image: CachedNetworkImageProvider(imageUrl),
|
child: Container(
|
||||||
fit: BoxFit.cover,
|
width: coverSize,
|
||||||
)
|
height: coverSize,
|
||||||
: null,
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
// Match source memCacheWidth for smooth Hero
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => const SizedBox(),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
size: coverSize * 0.43,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
size: coverSize * 0.43,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(
|
|
||||||
Icons.album_rounded,
|
|
||||||
size: coverSize * 0.43,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -910,6 +1026,29 @@ 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),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
List<Album> _providerAlbums = [];
|
List<Album> _providerAlbums = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isFavorite = false;
|
bool _isFavorite = false;
|
||||||
|
bool _isInLibrary = false;
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
String? _artistDescription;
|
String? _artistDescription;
|
||||||
String? _artistImageUrl;
|
String? _artistImageUrl;
|
||||||
|
MusicAssistantProvider? _maProvider;
|
||||||
|
|
||||||
// View preferences
|
// View preferences
|
||||||
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
||||||
@@ -52,6 +54,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.artist.favorite ?? false;
|
_isFavorite = widget.artist.favorite ?? false;
|
||||||
|
_isInLibrary = _checkIfInLibrary(widget.artist);
|
||||||
// Use initial image URL immediately for smooth hero animation
|
// Use initial image URL immediately for smooth hero animation
|
||||||
_artistImageUrl = widget.initialImageUrl;
|
_artistImageUrl = widget.initialImageUrl;
|
||||||
_loadViewPreferences();
|
_loadViewPreferences();
|
||||||
@@ -66,11 +69,60 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
_loadArtistImage();
|
_loadArtistImage();
|
||||||
// Note: _extractColors is called by _loadArtistImage after image loads
|
// 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 {
|
Future<void> _loadViewPreferences() async {
|
||||||
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
|
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
|
||||||
final viewMode = await SettingsService.getArtistAlbumsViewMode();
|
final viewMode = await SettingsService.getArtistAlbumsViewMode();
|
||||||
@@ -172,7 +224,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
orElse: () => widget.artist.providerMappings!.first,
|
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;
|
actualItemId = mapping.itemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +255,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
throw Exception('Could not determine library ID for this artist');
|
throw Exception('Could not determine library ID for this artist');
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'artist',
|
mediaType: 'artist',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
@@ -243,6 +295,110 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if artist is in library
|
||||||
|
bool _checkIfInLibrary(Artist artist) {
|
||||||
|
if (artist.provider == 'library') return true;
|
||||||
|
return artist.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle library status
|
||||||
|
Future<void> _toggleLibrary() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newState = !_isInLibrary;
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
if (newState) {
|
||||||
|
// Add to library - MUST use non-library provider
|
||||||
|
String? actualProvider;
|
||||||
|
String? actualItemId;
|
||||||
|
|
||||||
|
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
|
||||||
|
// For adding to library, we MUST use a non-library provider
|
||||||
|
final nonLibraryMapping = widget.artist.providerMappings!.where(
|
||||||
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
||||||
|
).firstOrNull;
|
||||||
|
|
||||||
|
if (nonLibraryMapping != null) {
|
||||||
|
actualProvider = nonLibraryMapping.providerDomain;
|
||||||
|
actualItemId = nonLibraryMapping.itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to item's own provider if no non-library mapping found
|
||||||
|
if (actualProvider == null || actualItemId == null) {
|
||||||
|
if (widget.artist.provider != 'library') {
|
||||||
|
actualProvider = widget.artist.provider;
|
||||||
|
actualItemId = widget.artist.itemId;
|
||||||
|
} else {
|
||||||
|
// Item is library-only, can't add
|
||||||
|
_logger.log('Cannot add to library: artist is library-only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
||||||
|
success = await maProvider.addToLibrary(
|
||||||
|
mediaType: 'artist',
|
||||||
|
provider: actualProvider,
|
||||||
|
itemId: actualItemId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Remove from library
|
||||||
|
int? libraryItemId;
|
||||||
|
if (widget.artist.provider == 'library') {
|
||||||
|
libraryItemId = int.tryParse(widget.artist.itemId);
|
||||||
|
} else if (widget.artist.providerMappings != null) {
|
||||||
|
final libraryMapping = widget.artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.providerInstance == 'library',
|
||||||
|
orElse: () => widget.artist.providerMappings!.first,
|
||||||
|
);
|
||||||
|
if (libraryMapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItemId == null) {
|
||||||
|
_logger.log('Cannot remove from library: no library ID found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = await maProvider.removeFromLibrary(
|
||||||
|
mediaType: 'artist',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setState(() {
|
||||||
|
_isInLibrary = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error toggling artist library: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update library: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showRadioMenu(BuildContext context) {
|
void _showRadioMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
final players = maProvider.availablePlayers;
|
||||||
@@ -587,8 +743,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// Match source memCacheWidth for smooth Hero animation
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => const SizedBox(),
|
||||||
errorWidget: (_, __, ___) => Icon(
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: coverSize * 0.5,
|
size: coverSize * 0.5,
|
||||||
@@ -712,6 +872,29 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Library Button
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _toggleLibrary,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_isInLibrary ? Icons.library_add_check : Icons.library_add,
|
||||||
|
color: _isInLibrary
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
orElse: () => widget.audiobook.providerMappings!.first,
|
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;
|
actualItemId = mapping.itemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +170,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
throw Exception('Could not determine library ID for this audiobook');
|
throw Exception('Could not determine library ID for this audiobook');
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'audiobook',
|
mediaType: 'audiobook',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
@@ -512,6 +512,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// Match source memCacheWidth for smooth Hero animation
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
return;
|
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 global player is expanded, collapse it first
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await context.read<MusicAssistantProvider>().loadLibrary();
|
await context.read<MusicAssistantProvider>().loadLibrary();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await context.read<MusicAssistantProvider>().loadLibrary();
|
await context.read<MusicAssistantProvider>().loadLibrary();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,17 +30,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api != null) {
|
final playlists = await maProvider.getPlaylists(limit: 100);
|
||||||
final playlists = await maProvider.api!.getPlaylists(limit: 100);
|
setState(() {
|
||||||
setState(() {
|
_playlists = playlists;
|
||||||
_playlists = playlists;
|
_isLoading = false;
|
||||||
_isLoading = false;
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -89,7 +83,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: _loadPlaylists,
|
onRefresh: _loadPlaylists,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: _playlists.length,
|
itemCount: _playlists.length,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import '../widgets/artist_row.dart';
|
|||||||
import '../widgets/track_row.dart';
|
import '../widgets/track_row.dart';
|
||||||
import '../widgets/audiobook_row.dart';
|
import '../widgets/audiobook_row.dart';
|
||||||
import '../widgets/series_row.dart';
|
import '../widgets/series_row.dart';
|
||||||
|
import '../widgets/playlist_row.dart';
|
||||||
|
import '../widgets/radio_station_row.dart';
|
||||||
import '../widgets/common/disconnected_state.dart';
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
@@ -35,6 +37,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
bool _showFavoriteAlbums = false;
|
bool _showFavoriteAlbums = false;
|
||||||
bool _showFavoriteArtists = false;
|
bool _showFavoriteArtists = false;
|
||||||
bool _showFavoriteTracks = false;
|
bool _showFavoriteTracks = false;
|
||||||
|
bool _showFavoritePlaylists = false;
|
||||||
|
bool _showFavoriteRadioStations = false;
|
||||||
// Audiobook rows (default off)
|
// Audiobook rows (default off)
|
||||||
bool _showContinueListeningAudiobooks = false;
|
bool _showContinueListeningAudiobooks = false;
|
||||||
bool _showDiscoverAudiobooks = false;
|
bool _showDiscoverAudiobooks = false;
|
||||||
@@ -73,6 +77,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||||
|
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||||
@@ -85,6 +91,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
_showFavoriteAlbums = showFavAlbums;
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
_showFavoriteArtists = showFavArtists;
|
_showFavoriteArtists = showFavArtists;
|
||||||
_showFavoriteTracks = showFavTracks;
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
_showFavoritePlaylists = showFavPlaylists;
|
||||||
|
_showFavoriteRadioStations = showFavRadio;
|
||||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
_showDiscoverSeries = showDiscSeries;
|
_showDiscoverSeries = showDiscSeries;
|
||||||
@@ -194,7 +202,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
RefreshIndicator(
|
RefreshIndicator(
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
child: _buildConnectedView(context, maProvider),
|
child: _buildConnectedView(context, maProvider),
|
||||||
),
|
),
|
||||||
// Connecting banner overlay (doesn't affect layout)
|
// Connecting banner overlay (doesn't affect layout)
|
||||||
@@ -373,6 +381,24 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
loadTracks: () => provider.getFavoriteTracks(),
|
loadTracks: () => provider.getFavoriteTracks(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
);
|
);
|
||||||
|
case 'favorite-playlists':
|
||||||
|
if (!_showFavoritePlaylists) return null;
|
||||||
|
return PlaylistRow(
|
||||||
|
key: const ValueKey('favorite-playlists'),
|
||||||
|
title: S.of(context)!.favoritePlaylists,
|
||||||
|
loadPlaylists: () => provider.getFavoritePlaylists(),
|
||||||
|
heroTagSuffix: 'home',
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
|
case 'favorite-radio-stations':
|
||||||
|
if (!_showFavoriteRadioStations) return null;
|
||||||
|
return RadioStationRow(
|
||||||
|
key: const ValueKey('favorite-radio-stations'),
|
||||||
|
title: S.of(context)!.favoriteRadioStations,
|
||||||
|
loadRadioStations: () => provider.getFavoriteRadioStations(),
|
||||||
|
heroTagSuffix: 'home',
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-152
@@ -114,6 +114,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
final ScrollController _authorsScrollController = ScrollController();
|
final ScrollController _authorsScrollController = ScrollController();
|
||||||
final ScrollController _audiobooksScrollController = ScrollController();
|
final ScrollController _audiobooksScrollController = ScrollController();
|
||||||
final ScrollController _seriesScrollController = ScrollController();
|
final ScrollController _seriesScrollController = ScrollController();
|
||||||
|
final ScrollController _podcastsScrollController = ScrollController();
|
||||||
|
final ScrollController _radioScrollController = ScrollController();
|
||||||
|
|
||||||
int get _tabCount {
|
int get _tabCount {
|
||||||
switch (_selectedMediaType) {
|
switch (_selectedMediaType) {
|
||||||
@@ -585,33 +587,27 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
_authorsScrollController.dispose();
|
_authorsScrollController.dispose();
|
||||||
_audiobooksScrollController.dispose();
|
_audiobooksScrollController.dispose();
|
||||||
_seriesScrollController.dispose();
|
_seriesScrollController.dispose();
|
||||||
|
_podcastsScrollController.dispose();
|
||||||
|
_radioScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
|
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api != null) {
|
final playlists = await maProvider.getPlaylists(
|
||||||
final playlists = await maProvider.api!.getPlaylists(
|
limit: 100,
|
||||||
limit: 100,
|
favoriteOnly: favoriteOnly,
|
||||||
favoriteOnly: favoriteOnly,
|
);
|
||||||
);
|
if (mounted) {
|
||||||
if (mounted) {
|
// PERF: Pre-sort once on load, not on every build
|
||||||
// PERF: Pre-sort once on load, not on every build
|
final sorted = List<Playlist>.from(playlists)
|
||||||
final sorted = List<Playlist>.from(playlists)
|
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
|
||||||
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
|
setState(() {
|
||||||
setState(() {
|
_playlists = playlists;
|
||||||
_playlists = playlists;
|
_sortedPlaylists = sorted;
|
||||||
_sortedPlaylists = sorted;
|
_playlistNames = sorted.map((p) => p.name ?? '').toList();
|
||||||
_playlistNames = sorted.map((p) => p.name ?? '').toList();
|
_isLoadingPlaylists = false;
|
||||||
_isLoadingPlaylists = false;
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoadingPlaylists = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,48 +661,39 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
});
|
});
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api != null) {
|
_logger.log('📚 Calling getAudiobooks...');
|
||||||
_logger.log('📚 Calling API getAudiobooks...');
|
final audiobooks = await maProvider.getAudiobooks(
|
||||||
final audiobooks = await maProvider.api!.getAudiobooks(
|
limit: 10000, // Large limit to get all audiobooks
|
||||||
limit: 10000, // Large limit to get all audiobooks
|
favoriteOnly: favoriteOnly,
|
||||||
favoriteOnly: favoriteOnly,
|
);
|
||||||
);
|
_logger.log('📚 Returned ${audiobooks.length} audiobooks');
|
||||||
_logger.log('📚 API returned ${audiobooks.length} audiobooks');
|
if (audiobooks.isNotEmpty) {
|
||||||
if (audiobooks.isNotEmpty) {
|
_logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}');
|
||||||
_logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}');
|
}
|
||||||
}
|
if (mounted) {
|
||||||
if (mounted) {
|
// PERF: Pre-sort and pre-group once on load, not on every build
|
||||||
// PERF: Pre-sort and pre-group once on load, not on every build
|
final sortedAlpha = List<Audiobook>.from(audiobooks)
|
||||||
final sortedAlpha = List<Audiobook>.from(audiobooks)
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
||||||
|
|
||||||
// Group audiobooks by author
|
// Group audiobooks by author
|
||||||
final authorMap = <String, List<Audiobook>>{};
|
final authorMap = <String, List<Audiobook>>{};
|
||||||
for (final book in audiobooks) {
|
for (final book in audiobooks) {
|
||||||
final authorName = book.authorsString;
|
final authorName = book.authorsString;
|
||||||
authorMap.putIfAbsent(authorName, () => []).add(book);
|
authorMap.putIfAbsent(authorName, () => []).add(book);
|
||||||
}
|
}
|
||||||
final sortedAuthors = authorMap.keys.toList()..sort();
|
final sortedAuthors = authorMap.keys.toList()..sort();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_audiobooks = audiobooks;
|
_audiobooks = audiobooks;
|
||||||
_sortedAudiobooks = sortedAlpha;
|
_sortedAudiobooks = sortedAlpha;
|
||||||
_audiobookNames = sortedAlpha.map((a) => a.name).toList();
|
_audiobookNames = sortedAlpha.map((a) => a.name).toList();
|
||||||
_groupedAudiobooksByAuthor = authorMap;
|
_groupedAudiobooksByAuthor = authorMap;
|
||||||
_sortedAuthorNames = sortedAuthors;
|
_sortedAuthorNames = sortedAuthors;
|
||||||
_isLoadingAudiobooks = false;
|
_isLoadingAudiobooks = false;
|
||||||
});
|
});
|
||||||
_logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}');
|
_logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}');
|
||||||
// Fetch author images in background
|
// Fetch author images in background
|
||||||
_fetchAuthorImages(audiobooks);
|
_fetchAuthorImages(audiobooks);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_logger.log('📚 API is null!');
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoadingAudiobooks = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1014,7 +1001,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
colorScheme.background,
|
colorScheme.background,
|
||||||
colorScheme.background.withOpacity(0),
|
colorScheme.surface.withOpacity(0),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1156,7 +1143,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: _showFavoritesOnly ? Colors.red : colorScheme.surface,
|
color: _showFavoritesOnly ? Colors.red : colorScheme.background,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shadowColor: Colors.black26,
|
shadowColor: Colors.black26,
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
@@ -1177,7 +1164,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.background,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shadowColor: Colors.black26,
|
shadowColor: Colors.black26,
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
@@ -1330,7 +1317,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// Match music artists tab layout - no header, direct list/grid
|
// Match music artists tab layout - no header, direct list/grid
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
|
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
|
||||||
child: LetterScrollbar(
|
child: LetterScrollbar(
|
||||||
controller: _authorsScrollController,
|
controller: _authorsScrollController,
|
||||||
@@ -1393,6 +1380,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
memCacheWidth: 128,
|
||||||
|
memCacheHeight: 128,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Text(
|
placeholder: (_, __) => Text(
|
||||||
@@ -1478,6 +1467,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
@@ -1568,6 +1559,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
placeholder: (_, __) => const SizedBox(),
|
||||||
errorWidget: (_, __, ___) => Icon(
|
errorWidget: (_, __, ___) => Icon(
|
||||||
MdiIcons.bookOutline,
|
MdiIcons.bookOutline,
|
||||||
@@ -1667,7 +1660,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// Match music albums tab layout - no header, direct list/grid
|
// Match music albums tab layout - no header, direct list/grid
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
|
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
|
||||||
child: LetterScrollbar(
|
child: LetterScrollbar(
|
||||||
controller: _audiobooksScrollController,
|
controller: _audiobooksScrollController,
|
||||||
@@ -1736,6 +1729,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 512,
|
||||||
|
memCacheHeight: 512,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
placeholder: (_, __) => const SizedBox(),
|
||||||
@@ -1955,6 +1950,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: firstCover,
|
imageUrl: firstCover,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
placeholder: (_, __) => Icon(
|
placeholder: (_, __) => Icon(
|
||||||
Icons.collections_bookmark_rounded,
|
Icons.collections_bookmark_rounded,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -2269,12 +2266,20 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// Use consistent 256 for all views to improve hero animation smoothness (matches detail screen)
|
// Use consistent 256 for all views to improve hero animation smoothness (matches detail screen)
|
||||||
const cacheSize = 256;
|
const cacheSize = 256;
|
||||||
|
|
||||||
|
// Generate podcast names for letter scrollbar
|
||||||
|
final podcastNames = podcasts.map((p) => p.name).toList();
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () => maProvider.loadPodcasts(),
|
onRefresh: () => maProvider.loadPodcasts(),
|
||||||
child: _podcastsViewMode == 'list'
|
child: LetterScrollbar(
|
||||||
|
controller: _podcastsScrollController,
|
||||||
|
items: podcastNames,
|
||||||
|
onDragStateChanged: _onLetterScrollbarDragChanged,
|
||||||
|
child: _podcastsViewMode == 'list'
|
||||||
? ListView.builder(
|
? ListView.builder(
|
||||||
|
controller: _podcastsScrollController,
|
||||||
key: const PageStorageKey<String>('podcasts_list'),
|
key: const PageStorageKey<String>('podcasts_list'),
|
||||||
cacheExtent: 1000,
|
cacheExtent: 1000,
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
||||||
@@ -2301,7 +2306,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
// No memCacheWidth - decode at native resolution for smooth hero
|
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
placeholder: (_, __) => const SizedBox(),
|
||||||
@@ -2340,6 +2347,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: GridView.builder(
|
: GridView.builder(
|
||||||
|
controller: _podcastsScrollController,
|
||||||
key: PageStorageKey<String>('podcasts_grid_$_podcastsViewMode'),
|
key: PageStorageKey<String>('podcasts_grid_$_podcastsViewMode'),
|
||||||
cacheExtent: 1000,
|
cacheExtent: 1000,
|
||||||
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
||||||
@@ -2355,6 +2363,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
return _buildPodcastCard(podcast, maProvider, cacheSize);
|
return _buildPodcastCard(podcast, maProvider, cacheSize);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2382,7 +2391,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
// No memCacheWidth - decode at native resolution for smooth hero
|
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
placeholder: (_, __) => const SizedBox(),
|
||||||
@@ -2484,12 +2495,20 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// PERF: Use appropriate cache size based on view mode
|
// PERF: Use appropriate cache size based on view mode
|
||||||
final cacheSize = _radioViewMode == 'grid3' ? 200 : 256;
|
final cacheSize = _radioViewMode == 'grid3' ? 200 : 256;
|
||||||
|
|
||||||
|
// Generate radio station names for letter scrollbar
|
||||||
|
final radioNames = radioStations.map((s) => s.name).toList();
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () => maProvider.loadRadioStations(),
|
onRefresh: () => maProvider.loadRadioStations(),
|
||||||
child: _radioViewMode == 'list'
|
child: LetterScrollbar(
|
||||||
|
controller: _radioScrollController,
|
||||||
|
items: radioNames,
|
||||||
|
onDragStateChanged: _onLetterScrollbarDragChanged,
|
||||||
|
child: _radioViewMode == 'list'
|
||||||
? ListView.builder(
|
? ListView.builder(
|
||||||
|
controller: _radioScrollController,
|
||||||
key: const PageStorageKey<String>('radio_stations_list'),
|
key: const PageStorageKey<String>('radio_stations_list'),
|
||||||
cacheExtent: 1000,
|
cacheExtent: 1000,
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
||||||
@@ -2555,6 +2574,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: GridView.builder(
|
: GridView.builder(
|
||||||
|
controller: _radioScrollController,
|
||||||
key: PageStorageKey<String>('radio_stations_grid_$_radioViewMode'),
|
key: PageStorageKey<String>('radio_stations_grid_$_radioViewMode'),
|
||||||
cacheExtent: 1000,
|
cacheExtent: 1000,
|
||||||
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
|
||||||
@@ -2570,6 +2590,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
return _buildRadioCard(station, maProvider, cacheSize);
|
return _buildRadioCard(station, maProvider, cacheSize);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2642,89 +2663,90 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// ============ ARTISTS TAB ============
|
// ============ ARTISTS TAB ============
|
||||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||||
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
||||||
|
// Artist filtering (albumArtistsOnly) is now done at API level in library_provider
|
||||||
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
||||||
selector: (_, provider) => (provider.artists, provider.isLoading),
|
selector: (_, provider) => (provider.artists, provider.isLoading),
|
||||||
builder: (context, data, _) {
|
builder: (context, data, _) {
|
||||||
final (allArtists, isLoading) = data;
|
final (allArtists, isLoading) = data;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by favorites if enabled
|
// Filter by favorites if enabled (artist filter is done at API level)
|
||||||
final artists = _showFavoritesOnly
|
final artists = _showFavoritesOnly
|
||||||
? allArtists.where((a) => a.favorite == true).toList()
|
? allArtists.where((a) => a.favorite == true).toList()
|
||||||
: allArtists;
|
: allArtists;
|
||||||
|
|
||||||
if (artists.isEmpty) {
|
if (artists.isEmpty) {
|
||||||
if (_showFavoritesOnly) {
|
if (_showFavoritesOnly) {
|
||||||
return EmptyState.custom(
|
return EmptyState.custom(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.favorite_border,
|
icon: Icons.favorite_border,
|
||||||
title: l10n.noFavoriteArtists,
|
title: l10n.noFavoriteArtists,
|
||||||
subtitle: l10n.tapHeartArtist,
|
subtitle: l10n.tapHeartArtist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EmptyState.artists(
|
||||||
|
context: context,
|
||||||
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort artists alphabetically for letter scrollbar
|
||||||
|
final sortedArtists = List<Artist>.from(artists)
|
||||||
|
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
|
||||||
|
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
|
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
|
child: LetterScrollbar(
|
||||||
|
controller: _artistsScrollController,
|
||||||
|
items: artistNames,
|
||||||
|
onDragStateChanged: _onLetterScrollbarDragChanged,
|
||||||
|
child: _artistsViewMode == 'list'
|
||||||
|
? ListView.builder(
|
||||||
|
controller: _artistsScrollController,
|
||||||
|
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
|
||||||
|
cacheExtent: 1000,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
|
itemCount: sortedArtists.length,
|
||||||
|
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = sortedArtists[index];
|
||||||
|
return _buildArtistTile(
|
||||||
|
context,
|
||||||
|
artist,
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: GridView.builder(
|
||||||
|
controller: _artistsScrollController,
|
||||||
|
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
|
||||||
|
cacheExtent: 1000,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
|
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
|
||||||
|
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
),
|
||||||
|
itemCount: sortedArtists.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = sortedArtists[index];
|
||||||
|
return _buildArtistGridCard(context, artist);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return EmptyState.artists(
|
|
||||||
context: context,
|
|
||||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort artists alphabetically for letter scrollbar
|
|
||||||
final sortedArtists = List<Artist>.from(artists)
|
|
||||||
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
|
|
||||||
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
backgroundColor: colorScheme.surface,
|
|
||||||
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
|
||||||
child: LetterScrollbar(
|
|
||||||
controller: _artistsScrollController,
|
|
||||||
items: artistNames,
|
|
||||||
onDragStateChanged: _onLetterScrollbarDragChanged,
|
|
||||||
child: _artistsViewMode == 'list'
|
|
||||||
? ListView.builder(
|
|
||||||
controller: _artistsScrollController,
|
|
||||||
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
|
|
||||||
cacheExtent: 1000,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addRepaintBoundaries: false,
|
|
||||||
itemCount: sortedArtists.length,
|
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = sortedArtists[index];
|
|
||||||
return _buildArtistTile(
|
|
||||||
context,
|
|
||||||
artist,
|
|
||||||
key: ValueKey(artist.uri ?? artist.itemId),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: GridView.builder(
|
|
||||||
controller: _artistsScrollController,
|
|
||||||
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
|
|
||||||
cacheExtent: 1000,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addRepaintBoundaries: false,
|
|
||||||
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
|
|
||||||
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
|
|
||||||
crossAxisSpacing: 16,
|
|
||||||
mainAxisSpacing: 16,
|
|
||||||
),
|
|
||||||
itemCount: sortedArtists.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = sortedArtists[index];
|
|
||||||
return _buildArtistGridCard(context, artist);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2879,7 +2901,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
child: LetterScrollbar(
|
child: LetterScrollbar(
|
||||||
controller: _albumsScrollController,
|
controller: _albumsScrollController,
|
||||||
@@ -2949,6 +2971,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
placeholder: (_, __) => const SizedBox(),
|
placeholder: (_, __) => const SizedBox(),
|
||||||
errorWidget: (_, __, ___) => Icon(
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
@@ -3016,7 +3040,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// PERF: Use pre-sorted lists (sorted once on load)
|
// PERF: Use pre-sorted lists (sorted once on load)
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
|
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
|
||||||
child: LetterScrollbar(
|
child: LetterScrollbar(
|
||||||
controller: _playlistsScrollController,
|
controller: _playlistsScrollController,
|
||||||
@@ -3215,7 +3239,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
// PERF: Use pre-sorted list (sorted once on load)
|
// PERF: Use pre-sorted list (sorted once on load)
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.background,
|
||||||
onRefresh: _loadFavoriteTracks,
|
onRefresh: _loadFavoriteTracks,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
key: const PageStorageKey<String>('library_tracks_list'),
|
key: const PageStorageKey<String>('library_tracks_list'),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
List<MediaItem> _episodes = [];
|
List<MediaItem> _episodes = [];
|
||||||
bool _isLoadingEpisodes = false;
|
bool _isLoadingEpisodes = false;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
|
bool _isInLibrary = false;
|
||||||
String? _expandedEpisodeId; // Track which episode is expanded for actions
|
String? _expandedEpisodeId; // Track which episode is expanded for actions
|
||||||
|
|
||||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
@@ -42,6 +43,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_isInLibrary = _checkIfInLibrary(widget.podcast);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
Future.delayed(const Duration(milliseconds: 350), () {
|
Future.delayed(const Duration(milliseconds: 350), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -52,6 +54,110 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if podcast is in library
|
||||||
|
bool _checkIfInLibrary(MediaItem podcast) {
|
||||||
|
if (podcast.provider == 'library') return true;
|
||||||
|
return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle library status
|
||||||
|
Future<void> _toggleLibrary() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newState = !_isInLibrary;
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
if (newState) {
|
||||||
|
// Add to library - MUST use non-library provider
|
||||||
|
String? actualProvider;
|
||||||
|
String? actualItemId;
|
||||||
|
|
||||||
|
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
|
||||||
|
// For adding to library, we MUST use a non-library provider
|
||||||
|
final nonLibraryMapping = widget.podcast.providerMappings!.where(
|
||||||
|
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
||||||
|
).firstOrNull;
|
||||||
|
|
||||||
|
if (nonLibraryMapping != null) {
|
||||||
|
actualProvider = nonLibraryMapping.providerDomain;
|
||||||
|
actualItemId = nonLibraryMapping.itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to item's own provider if no non-library mapping found
|
||||||
|
if (actualProvider == null || actualItemId == null) {
|
||||||
|
if (widget.podcast.provider != 'library') {
|
||||||
|
actualProvider = widget.podcast.provider;
|
||||||
|
actualItemId = widget.podcast.itemId;
|
||||||
|
} else {
|
||||||
|
// Item is library-only, can't add
|
||||||
|
_logger.log('Cannot add to library: podcast is library-only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
|
||||||
|
success = await maProvider.addToLibrary(
|
||||||
|
mediaType: 'podcast',
|
||||||
|
provider: actualProvider,
|
||||||
|
itemId: actualItemId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Remove from library
|
||||||
|
int? libraryItemId;
|
||||||
|
if (widget.podcast.provider == 'library') {
|
||||||
|
libraryItemId = int.tryParse(widget.podcast.itemId);
|
||||||
|
} else if (widget.podcast.providerMappings != null) {
|
||||||
|
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
|
||||||
|
(m) => m.providerInstance == 'library',
|
||||||
|
orElse: () => widget.podcast.providerMappings!.first,
|
||||||
|
);
|
||||||
|
if (libraryMapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItemId == null) {
|
||||||
|
_logger.log('Cannot remove from library: no library ID found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = await maProvider.removeFromLibrary(
|
||||||
|
mediaType: 'podcast',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setState(() {
|
||||||
|
_isInLibrary = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error toggling podcast library: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update library: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _extractColors() async {
|
Future<void> _extractColors() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
// Use initialImageUrl first for consistency with hero animation
|
// Use initialImageUrl first for consistency with hero animation
|
||||||
@@ -249,11 +355,14 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
|
||||||
// CRITICAL: Use initialImageUrl for hero animation consistency
|
// watch() rebuilds on ANY provider change (library updates, etc.) causing animation jank
|
||||||
// The library passes the same URL it uses for display, ensuring cache hit
|
// select() only rebuilds when the specific podcast image URL changes
|
||||||
// Only fall back to provider URL if initialImageUrl is null
|
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
|
||||||
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
|
(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>(
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
(provider) => provider.adaptiveTheme,
|
(provider) => provider.adaptiveTheme,
|
||||||
@@ -325,6 +434,9 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// Match source memCacheWidth for smooth Hero animation
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
@@ -383,6 +495,78 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
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
|
// Description
|
||||||
if (description != null && description.isNotEmpty) ...[
|
if (description != null && description.isNotEmpty) ...[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
@@ -483,7 +667,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
|||||||
final duration = episode.duration;
|
final duration = episode.duration;
|
||||||
final episodeId = episode.uri ?? episode.itemId;
|
final episodeId = episode.uri ?? episode.itemId;
|
||||||
final isExpanded = _expandedEpisodeId == episodeId;
|
final isExpanded = _expandedEpisodeId == episodeId;
|
||||||
final episodeImageUrl = maProvider.getImageUrl(episode, size: 256);
|
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
|
||||||
|
|
||||||
// Try to get release date from metadata
|
// Try to get release date from metadata
|
||||||
final releaseDate = _formatReleaseDate(episode.metadata);
|
final releaseDate = _formatReleaseDate(episode.metadata);
|
||||||
|
|||||||
+1755
-499
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _showFavoriteAlbums = false;
|
bool _showFavoriteAlbums = false;
|
||||||
bool _showFavoriteArtists = false;
|
bool _showFavoriteArtists = false;
|
||||||
bool _showFavoriteTracks = false;
|
bool _showFavoriteTracks = false;
|
||||||
|
bool _showFavoritePlaylists = false;
|
||||||
|
bool _showFavoriteRadioStations = false;
|
||||||
// Audiobook home rows (default off)
|
// Audiobook home rows (default off)
|
||||||
bool _showContinueListeningAudiobooks = false;
|
bool _showContinueListeningAudiobooks = false;
|
||||||
bool _showDiscoverAudiobooks = false;
|
bool _showDiscoverAudiobooks = false;
|
||||||
@@ -41,8 +43,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Player settings
|
// Player settings
|
||||||
bool _preferLocalPlayer = false;
|
bool _preferLocalPlayer = false;
|
||||||
bool _smartSortPlayers = false;
|
bool _smartSortPlayers = false;
|
||||||
|
bool _volumePrecisionMode = true;
|
||||||
// Hint settings
|
// Hint settings
|
||||||
bool _showHints = true;
|
bool _showHints = true;
|
||||||
|
// Library settings
|
||||||
|
bool _showOnlyArtistsWithAlbums = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -68,6 +73,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||||
|
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||||
|
|
||||||
// Load audiobook home row settings
|
// Load audiobook home row settings
|
||||||
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||||
@@ -90,10 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Load player settings
|
// Load player settings
|
||||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
|
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||||
|
|
||||||
// Load hint settings
|
// Load hint settings
|
||||||
final showHints = await SettingsService.getShowHints();
|
final showHints = await SettingsService.getShowHints();
|
||||||
|
|
||||||
|
// Load library settings
|
||||||
|
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -102,6 +113,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_showFavoriteAlbums = showFavAlbums;
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
_showFavoriteArtists = showFavArtists;
|
_showFavoriteArtists = showFavArtists;
|
||||||
_showFavoriteTracks = showFavTracks;
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
_showFavoritePlaylists = showFavPlaylists;
|
||||||
|
_showFavoriteRadioStations = showFavRadio;
|
||||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
_showDiscoverSeries = showDiscSeries;
|
_showDiscoverSeries = showDiscSeries;
|
||||||
@@ -110,7 +123,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_libraryEnabled = libraryEnabled;
|
_libraryEnabled = libraryEnabled;
|
||||||
_preferLocalPlayer = preferLocal;
|
_preferLocalPlayer = preferLocal;
|
||||||
_smartSortPlayers = smartSort;
|
_smartSortPlayers = smartSort;
|
||||||
|
_volumePrecisionMode = volumePrecision;
|
||||||
_showHints = showHints;
|
_showHints = showHints;
|
||||||
|
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +159,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
|
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
|
||||||
case 'favorite-tracks':
|
case 'favorite-tracks':
|
||||||
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
|
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
|
||||||
|
case 'favorite-playlists':
|
||||||
|
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
|
||||||
|
case 'favorite-radio-stations':
|
||||||
|
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
|
||||||
default:
|
default:
|
||||||
return {'title': rowId, 'subtitle': ''};
|
return {'title': rowId, 'subtitle': ''};
|
||||||
}
|
}
|
||||||
@@ -170,6 +189,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return _showFavoriteArtists;
|
return _showFavoriteArtists;
|
||||||
case 'favorite-tracks':
|
case 'favorite-tracks':
|
||||||
return _showFavoriteTracks;
|
return _showFavoriteTracks;
|
||||||
|
case 'favorite-playlists':
|
||||||
|
return _showFavoritePlaylists;
|
||||||
|
case 'favorite-radio-stations':
|
||||||
|
return _showFavoriteRadioStations;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -215,6 +238,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_showFavoriteTracks = value;
|
_showFavoriteTracks = value;
|
||||||
SettingsService.setShowFavoriteTracks(value);
|
SettingsService.setShowFavoriteTracks(value);
|
||||||
break;
|
break;
|
||||||
|
case 'favorite-playlists':
|
||||||
|
_showFavoritePlaylists = value;
|
||||||
|
SettingsService.setShowFavoritePlaylists(value);
|
||||||
|
break;
|
||||||
|
case 'favorite-radio-stations':
|
||||||
|
_showFavoriteRadioStations = value;
|
||||||
|
SettingsService.setShowFavoriteRadioStations(value);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -281,13 +312,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Logo - same size as login screen (50% of screen width)
|
// Logo - same size as login screen (50% of screen width)
|
||||||
|
// Use color filter to make logo dark in light theme
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
|
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
|
||||||
child: Image.asset(
|
child: Theme.of(context).brightness == Brightness.light
|
||||||
'assets/images/ensemble_icon_transparent.png',
|
? ColorFiltered(
|
||||||
width: MediaQuery.of(context).size.width * 0.5,
|
colorFilter: const ColorFilter.mode(
|
||||||
fit: BoxFit.contain,
|
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
|
// Connection status box - centered with border radius like theme boxes
|
||||||
@@ -702,6 +746,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
contentPadding: EdgeInsets.zero,
|
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),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
@@ -745,6 +814,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Library section
|
||||||
|
Text(
|
||||||
|
S.of(context)!.library,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Show only artists with albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Hide artists that have no albums in your library',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: _showOnlyArtistsWithAlbums,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() => _showOnlyArtistsWithAlbums = value);
|
||||||
|
await SettingsService.setShowOnlyArtistsWithAlbums(value);
|
||||||
|
// Force sync library to apply the new filter at API level
|
||||||
|
if (mounted) {
|
||||||
|
context.read<MusicAssistantProvider>().forceLibrarySync();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Home Screen section
|
// Home Screen section
|
||||||
Text(
|
Text(
|
||||||
S.of(context)!.homeScreen,
|
S.of(context)!.homeScreen,
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ class AuthManager {
|
|||||||
host.startsWith('172.31.') ||
|
host.startsWith('172.31.') ||
|
||||||
host == 'localhost' ||
|
host == 'localhost' ||
|
||||||
host.startsWith('127.') ||
|
host.startsWith('127.') ||
|
||||||
host.endsWith('.local');
|
host.endsWith('.local') ||
|
||||||
|
host.endsWith('.ts.net');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to detect auth strategy for a given URL
|
/// Try to detect auth strategy for a given URL
|
||||||
|
|||||||
@@ -120,6 +120,22 @@ class CacheService {
|
|||||||
_logger.log('🗑️ Home screen cache invalidated');
|
_logger.log('🗑️ Home screen cache invalidated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invalidate home album caches (call when albums are added/removed from library)
|
||||||
|
void invalidateHomeAlbumCaches() {
|
||||||
|
_cachedRecentAlbums = null;
|
||||||
|
_recentAlbumsLastFetched = null;
|
||||||
|
_cachedDiscoverAlbums = null;
|
||||||
|
_discoverAlbumsLastFetched = null;
|
||||||
|
_logger.log('🗑️ Home album caches invalidated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate home artist caches (call when artists are added/removed from library)
|
||||||
|
void invalidateHomeArtistCaches() {
|
||||||
|
_cachedDiscoverArtists = null;
|
||||||
|
_discoverArtistsLastFetched = null;
|
||||||
|
_logger.log('🗑️ Home artist caches invalidated');
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DETAIL SCREEN CACHING
|
// DETAIL SCREEN CACHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -145,12 +161,19 @@ class CacheService {
|
|||||||
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
_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) {
|
void invalidateAlbumTracksCache(String albumId) {
|
||||||
_albumTracksCache.remove(albumId);
|
_albumTracksCache.remove(albumId);
|
||||||
_albumTracksCacheTime.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
|
/// Check if playlist tracks cache is valid
|
||||||
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
if (forceRefresh) return false;
|
if (forceRefresh) return false;
|
||||||
@@ -172,12 +195,19 @@ class CacheService {
|
|||||||
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
_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) {
|
void invalidatePlaylistTracksCache(String playlistId) {
|
||||||
_playlistTracksCache.remove(playlistId);
|
_playlistTracksCache.remove(playlistId);
|
||||||
_playlistTracksCacheTime.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
|
/// Check if artist albums cache is valid
|
||||||
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
if (forceRefresh) return false;
|
if (forceRefresh) return false;
|
||||||
@@ -199,6 +229,13 @@ class CacheService {
|
|||||||
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
|
_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
|
// SEARCH CACHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -224,6 +261,13 @@ class CacheService {
|
|||||||
_logger.log('✅ Cached search results for "$cacheKey"');
|
_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
|
// PLAYER CACHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -157,6 +157,16 @@ class DatabaseService {
|
|||||||
/// Clear all cached data (useful for logout)
|
/// Clear all cached data (useful for logout)
|
||||||
Future<void> clearAllCache() => db.clearAllCache();
|
Future<void> clearAllCache() => db.clearAllCache();
|
||||||
|
|
||||||
|
/// Clear cached items of a specific type (e.g., 'album', 'artist')
|
||||||
|
/// Used before re-syncing to remove stale items
|
||||||
|
Future<void> clearCacheForType(String itemType) => db.clearCache(itemType);
|
||||||
|
|
||||||
|
/// Mark a specific cached item as deleted
|
||||||
|
/// Used when removing items from library for immediate database update
|
||||||
|
Future<void> markCachedItemDeleted(String itemType, String itemId) {
|
||||||
|
return db.markItemsDeleted(itemType, [itemId]);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Player Cache Convenience Methods
|
// Player Cache Convenience Methods
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -293,16 +293,10 @@ class MusicAssistantAPI {
|
|||||||
// Handle event
|
// Handle event
|
||||||
final eventType = data['event'] as String?;
|
final eventType = data['event'] as String?;
|
||||||
if (eventType != null) {
|
if (eventType != null) {
|
||||||
_logger.log('Event received: $eventType');
|
|
||||||
|
|
||||||
// Get the object_id (player_id for player events)
|
// Get the object_id (player_id for player events)
|
||||||
final objectId = data['object_id'] as String?;
|
final objectId = data['object_id'] as String?;
|
||||||
final eventData = data['data'] as Map<String, dynamic>? ?? {};
|
final eventData = data['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
// Debug: Log full data for player events
|
|
||||||
if (eventType == 'player_added' || eventType == 'player_updated' || eventType == 'builtin_player') {
|
|
||||||
_logger.log('Event data: ${jsonEncode(eventData)} (player_id: $objectId)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include object_id in event data so listeners can filter by player
|
// Include object_id in event data so listeners can filter by player
|
||||||
final enrichedData = {
|
final enrichedData = {
|
||||||
@@ -528,7 +522,6 @@ class MusicAssistantAPI {
|
|||||||
/// Search for radio stations globally (across all providers like TuneIn)
|
/// Search for radio stations globally (across all providers like TuneIn)
|
||||||
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
|
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('📻 Searching radio stations for: $query');
|
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/search',
|
'music/search',
|
||||||
args: {
|
args: {
|
||||||
@@ -539,23 +532,18 @@ class MusicAssistantAPI {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final result = response['result'] as Map<String, dynamic>?;
|
final result = response['result'] as Map<String, dynamic>?;
|
||||||
if (result == null) {
|
if (result == null) return [];
|
||||||
_logger.log('📻 Radio search: no result');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Radio results might be under 'radios' or 'radio' key
|
// Radio results might be under 'radios' or 'radio' key
|
||||||
final radios = (result['radios'] as List<dynamic>?) ??
|
final radios = (result['radios'] as List<dynamic>?) ??
|
||||||
(result['radio'] as List<dynamic>?) ??
|
(result['radio'] as List<dynamic>?) ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
_logger.log('📻 Radio search found ${radios.length} results');
|
|
||||||
|
|
||||||
return radios
|
return radios
|
||||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('📻 Error searching radio stations: $e');
|
_logger.log('Error searching radio stations: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,7 +557,6 @@ class MusicAssistantAPI {
|
|||||||
bool? favoriteOnly,
|
bool? favoriteOnly,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('🎙️ Getting podcasts...');
|
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/podcasts/library_items',
|
'music/podcasts/library_items',
|
||||||
args: {
|
args: {
|
||||||
@@ -580,18 +567,13 @@ class MusicAssistantAPI {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
|
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
|
||||||
if (items == null) {
|
if (items == null) return [];
|
||||||
_logger.log('🎙️ Podcasts: result is null');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('🎙️ Found ${items.length} podcasts');
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('🎙️ Error getting podcasts: $e');
|
_logger.log('Error getting podcasts: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,9 +585,6 @@ class MusicAssistantAPI {
|
|||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('🎙️ Getting episodes for podcast: $podcastId, provider: $provider');
|
|
||||||
|
|
||||||
// Use the correct endpoint: music/podcasts/podcast_episodes
|
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/podcasts/podcast_episodes',
|
'music/podcasts/podcast_episodes',
|
||||||
args: {
|
args: {
|
||||||
@@ -614,21 +593,16 @@ class MusicAssistantAPI {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
_logger.log('🎙️ Response keys: ${response.keys.toList()}');
|
|
||||||
|
|
||||||
// Response should be a list of episodes
|
|
||||||
final episodes = response['result'] as List<dynamic>?;
|
final episodes = response['result'] as List<dynamic>?;
|
||||||
if (episodes != null && episodes.isNotEmpty) {
|
if (episodes != null && episodes.isNotEmpty) {
|
||||||
_logger.log('🎙️ Found ${episodes.length} episodes');
|
|
||||||
return episodes
|
return episodes
|
||||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('🎙️ No episodes found for podcast');
|
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('🎙️ Error getting podcast episodes: $e');
|
_logger.log('Error getting podcast episodes: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,7 +622,6 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
// No podcast image, try to get first episode's image
|
// No podcast image, try to get first episode's image
|
||||||
try {
|
try {
|
||||||
_logger.log('🎙️ No podcast cover, fetching episode cover for: ${podcast.name}');
|
|
||||||
final episodes = await getPodcastEpisodes(
|
final episodes = await getPodcastEpisodes(
|
||||||
podcast.itemId,
|
podcast.itemId,
|
||||||
provider: podcast.provider,
|
provider: podcast.provider,
|
||||||
@@ -658,12 +631,11 @@ class MusicAssistantAPI {
|
|||||||
final firstEpisode = episodes.first;
|
final firstEpisode = episodes.first;
|
||||||
final episodeImage = getImageUrl(firstEpisode, size: size);
|
final episodeImage = getImageUrl(firstEpisode, size: size);
|
||||||
if (episodeImage != null) {
|
if (episodeImage != null) {
|
||||||
_logger.log('🎙️ Using episode cover for podcast: ${podcast.name}');
|
|
||||||
return episodeImage;
|
return episodeImage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('🎙️ Error fetching episode cover: $e');
|
// Fall through to return podcast image
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to podcast image even if it might be low quality
|
// Fall back to podcast image even if it might be low quality
|
||||||
@@ -727,10 +699,6 @@ class MusicAssistantAPI {
|
|||||||
uri = 'library://podcast_episode/${episode.itemId}';
|
uri = 'library://podcast_episode/${episode.itemId}';
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('🎙️ Playing podcast episode: ${episode.name}');
|
|
||||||
_logger.log('🎙️ Episode URI: $uri');
|
|
||||||
_logger.log('🎙️ Episode itemId: ${episode.itemId}');
|
|
||||||
|
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'player_queues/play_media',
|
'player_queues/play_media',
|
||||||
args: {
|
args: {
|
||||||
@@ -740,7 +708,7 @@ class MusicAssistantAPI {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('🎙️ Error playing podcast episode: $e');
|
_logger.log('Error playing podcast episode: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,7 +726,6 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
// If specific libraries are enabled, use browse API for filtering
|
// If specific libraries are enabled, use browse API for filtering
|
||||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||||
_logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)');
|
|
||||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,8 +738,6 @@ class MusicAssistantAPI {
|
|||||||
if (authorId != null) 'author_id': authorId,
|
if (authorId != null) 'author_id': authorId,
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.log('📚 Calling music/audiobooks/library_items with args: $args');
|
|
||||||
|
|
||||||
final response = await _sendCommand(
|
final response = await _sendCommand(
|
||||||
'music/audiobooks/library_items',
|
'music/audiobooks/library_items',
|
||||||
args: args,
|
args: args,
|
||||||
@@ -780,65 +745,21 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
// Check for error in response
|
// Check for error in response
|
||||||
if (response.containsKey('error_code')) {
|
if (response.containsKey('error_code')) {
|
||||||
_logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}');
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
final items = response['result'] as List<dynamic>?;
|
final items = response['result'] as List<dynamic>?;
|
||||||
if (items == null) {
|
if (items == null) return [];
|
||||||
_logger.log('📚 Audiobooks: result is null');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('📚 Audiobooks: found ${items.length} items from API');
|
|
||||||
|
|
||||||
// Log first item's structure including metadata to find chapters/series
|
|
||||||
if (items.isNotEmpty) {
|
|
||||||
final firstItem = items.first as Map<String, dynamic>;
|
|
||||||
_logger.log('📚 First item keys: ${firstItem.keys.toList()}');
|
|
||||||
_logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}');
|
|
||||||
|
|
||||||
// Log metadata contents - this is where chapters/series info likely lives
|
|
||||||
if (firstItem.containsKey('metadata')) {
|
|
||||||
final metadata = firstItem['metadata'] as Map<String, dynamic>?;
|
|
||||||
if (metadata != null) {
|
|
||||||
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
|
|
||||||
for (final key in metadata.keys) {
|
|
||||||
final value = metadata[key];
|
|
||||||
if (value is List) {
|
|
||||||
_logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}');
|
|
||||||
} else if (value is Map) {
|
|
||||||
_logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}');
|
|
||||||
} else if (value != null) {
|
|
||||||
_logger.log('📚 metadata[$key]: $value');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for chapters at top level too
|
|
||||||
if (firstItem.containsKey('chapters')) {
|
|
||||||
_logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final audiobooks = <Audiobook>[];
|
final audiobooks = <Audiobook>[];
|
||||||
final parseErrors = <String>[];
|
|
||||||
|
|
||||||
for (int i = 0; i < items.length; i++) {
|
for (int i = 0; i < items.length; i++) {
|
||||||
try {
|
try {
|
||||||
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
||||||
audiobooks.add(book);
|
audiobooks.add(book);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
parseErrors.add('Item $i: $e');
|
// Skip items that fail to parse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseErrors.isNotEmpty) {
|
|
||||||
_logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}');
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks');
|
|
||||||
return audiobooks;
|
return audiobooks;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_logger.log('Error getting audiobooks: $e');
|
_logger.log('Error getting audiobooks: $e');
|
||||||
@@ -1318,6 +1239,7 @@ class MusicAssistantAPI {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final items = response['result'] as List<dynamic>?;
|
final items = response['result'] as List<dynamic>?;
|
||||||
|
_logger.log('📋 recently_played_items returned ${items?.length ?? 0} items');
|
||||||
if (items == null || items.isEmpty) {
|
if (items == null || items.isEmpty) {
|
||||||
_logger.log('⚠️ No recently played tracks found');
|
_logger.log('⚠️ No recently played tracks found');
|
||||||
return [];
|
return [];
|
||||||
@@ -1327,6 +1249,8 @@ class MusicAssistantAPI {
|
|||||||
final trackUris = <String>[];
|
final trackUris = <String>[];
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
|
final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
|
||||||
|
final name = (item as Map<String, dynamic>)['name'] as String?;
|
||||||
|
_logger.log(' 📀 Track: $name');
|
||||||
if (trackUri != null) {
|
if (trackUri != null) {
|
||||||
trackUris.add(trackUri);
|
trackUris.add(trackUri);
|
||||||
}
|
}
|
||||||
@@ -1356,12 +1280,17 @@ class MusicAssistantAPI {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
||||||
|
final trackName = fullTrack?['name'] as String?;
|
||||||
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
||||||
final albumUri = albumData?['uri'] as String?;
|
final albumUri = albumData?['uri'] as String?;
|
||||||
|
final albumName = albumData?['name'] as String?;
|
||||||
|
|
||||||
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
|
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
|
||||||
seenAlbumUris.add(albumUri);
|
seenAlbumUris.add(albumUri);
|
||||||
albumUris.add(albumUri);
|
albumUris.add(albumUri);
|
||||||
|
_logger.log(' 📀 Found album: $albumName from track: $trackName');
|
||||||
|
} else if (albumUri == null) {
|
||||||
|
_logger.log(' ⚠️ Track "$trackName" has no album');
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1630,20 +1559,38 @@ class MusicAssistantAPI {
|
|||||||
/// Remove item from favorites
|
/// Remove item from favorites
|
||||||
/// Requires the library_item_id (the numeric ID in the MA library)
|
/// Requires the library_item_id (the numeric ID in the MA library)
|
||||||
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
|
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
|
||||||
try {
|
await _sendCommand(
|
||||||
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
|
'music/favorites/remove_item',
|
||||||
|
args: {
|
||||||
|
'media_type': mediaType,
|
||||||
|
'library_item_id': libraryItemId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await _sendCommand(
|
// Library
|
||||||
'music/favorites/remove_item',
|
/// Add item to library using URI format
|
||||||
args: {
|
/// The item parameter should be a URI like "spotify://artist/4tZwfgrHOc3mvqYlEYSvVi"
|
||||||
'media_type': mediaType,
|
Future<void> addItemToLibrary(String mediaType, String itemId, String provider) async {
|
||||||
'library_item_id': libraryItemId,
|
final uri = '$provider://$mediaType/$itemId';
|
||||||
},
|
await _sendCommand(
|
||||||
);
|
'music/library/add_item',
|
||||||
} catch (e) {
|
args: {
|
||||||
_logger.log('Error removing from favorites: $e');
|
'item': uri,
|
||||||
rethrow;
|
},
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove item from library
|
||||||
|
/// Requires the library_item_id (the numeric ID in the MA library)
|
||||||
|
Future<void> removeItemFromLibrary(String mediaType, int libraryItemId) async {
|
||||||
|
await _sendCommand(
|
||||||
|
'music/library/remove_item',
|
||||||
|
args: {
|
||||||
|
'media_type': mediaType,
|
||||||
|
'library_item_id': libraryItemId,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
@@ -1948,6 +1895,86 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play album on player
|
||||||
|
Future<void> playAlbum(String playerId, Album album) async {
|
||||||
|
try {
|
||||||
|
final uri = album.uri ?? 'library://album/${album.itemId}';
|
||||||
|
_logger.log('💿 Playing album: ${album.name} with URI: $uri');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'replace',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error playing album: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add album tracks to queue
|
||||||
|
Future<void> addAlbumToQueue(String playerId, Album album) async {
|
||||||
|
try {
|
||||||
|
final uri = album.uri ?? 'library://album/${album.itemId}';
|
||||||
|
_logger.log('💿 Adding album to queue: ${album.name} with URI: $uri');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'add',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error adding album to queue: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add artist tracks to queue
|
||||||
|
Future<void> addArtistToQueue(String playerId, Artist artist) async {
|
||||||
|
try {
|
||||||
|
final uri = artist.uri ?? 'library://artist/${artist.itemId}';
|
||||||
|
_logger.log('🎤 Adding artist to queue: ${artist.name} with URI: $uri');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'add',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error adding artist to queue: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play playlist on player
|
||||||
|
Future<void> playPlaylist(String playerId, Playlist playlist) async {
|
||||||
|
try {
|
||||||
|
final uri = playlist.uri ?? 'library://playlist/${playlist.itemId}';
|
||||||
|
_logger.log('📝 Playing playlist: ${playlist.name} with URI: $uri');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'replace',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error playing playlist: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Play multiple tracks via queue
|
/// Play multiple tracks via queue
|
||||||
/// If clearQueue is true, replaces the queue (default behavior)
|
/// If clearQueue is true, replaces the queue (default behavior)
|
||||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||||
@@ -2426,6 +2453,39 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete an item from the queue
|
||||||
|
Future<void> queueCommandDeleteItem(String queueId, String itemId) async {
|
||||||
|
try {
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/delete_item',
|
||||||
|
args: {
|
||||||
|
'queue_id': queueId,
|
||||||
|
'item_id_or_index': itemId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error deleting queue item: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move an item to a new position in the queue
|
||||||
|
Future<void> queueCommandMoveItem(String queueId, String itemId, int newIndex) async {
|
||||||
|
try {
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/move_item',
|
||||||
|
args: {
|
||||||
|
'queue_id': queueId,
|
||||||
|
'item_id_or_index': itemId,
|
||||||
|
'new_index': newIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error moving queue item: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// BUILT-IN PLAYER MANAGEMENT
|
// BUILT-IN PLAYER MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2453,6 +2513,22 @@ class MusicAssistantAPI {
|
|||||||
return _eventStreams['player_added']!.stream;
|
return _eventStreams['player_added']!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream of media_item_added events (for refreshing library when items are added)
|
||||||
|
Stream<Map<String, dynamic>> get mediaItemAddedEvents {
|
||||||
|
if (!_eventStreams.containsKey('media_item_added')) {
|
||||||
|
_eventStreams['media_item_added'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
}
|
||||||
|
return _eventStreams['media_item_added']!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream of media_item_deleted events (for refreshing library when items are removed)
|
||||||
|
Stream<Map<String, dynamic>> get mediaItemDeletedEvents {
|
||||||
|
if (!_eventStreams.containsKey('media_item_deleted')) {
|
||||||
|
_eventStreams['media_item_deleted'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
}
|
||||||
|
return _eventStreams['media_item_deleted']!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
/// Register this device as a player with retry logic
|
/// Register this device as a player with retry logic
|
||||||
/// CRITICAL: This creates a player config in MA's settings.json
|
/// CRITICAL: This creates a player config in MA's settings.json
|
||||||
/// The server expects: player_id and player_name
|
/// The server expects: player_id and player_name
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class SettingsService {
|
|||||||
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
||||||
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||||
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
||||||
|
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
|
||||||
|
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations';
|
||||||
|
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
|
||||||
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||||
|
|
||||||
// Default row order
|
// Default row order
|
||||||
@@ -48,6 +51,8 @@ class SettingsService {
|
|||||||
'favorite-albums',
|
'favorite-albums',
|
||||||
'favorite-artists',
|
'favorite-artists',
|
||||||
'favorite-tracks',
|
'favorite-tracks',
|
||||||
|
'favorite-playlists',
|
||||||
|
'favorite-radio-stations',
|
||||||
];
|
];
|
||||||
|
|
||||||
// View Mode Settings
|
// View Mode Settings
|
||||||
@@ -83,6 +88,9 @@ class SettingsService {
|
|||||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||||
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||||
|
|
||||||
|
// Volume Precision Mode Settings
|
||||||
|
static const String _keyVolumePrecisionMode = 'volume_precision_mode'; // Enable hold-to-precision volume control
|
||||||
|
|
||||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||||
|
|
||||||
@@ -489,6 +497,37 @@ class SettingsService {
|
|||||||
await prefs.setBool(_keyShowFavoriteTracks, show);
|
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowFavoritePlaylists() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoritePlaylists(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoritePlaylists, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowFavoriteRadioStations() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoriteRadioStations(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoriteRadioStations, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library Artists Filter - show only artists that have albums in library
|
||||||
|
static Future<bool> getShowOnlyArtistsWithAlbums() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowOnlyArtistsWithAlbums(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show);
|
||||||
|
}
|
||||||
|
|
||||||
// Home Row Order
|
// Home Row Order
|
||||||
static Future<List<String>> getHomeRowOrder() async {
|
static Future<List<String>> getHomeRowOrder() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -812,6 +851,22 @@ class SettingsService {
|
|||||||
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume Precision Mode Settings
|
||||||
|
|
||||||
|
/// Get whether volume precision mode is enabled (default: true)
|
||||||
|
/// When enabled, holding still while adjusting volume enters precision mode
|
||||||
|
/// for fine-grained control (10x more precise)
|
||||||
|
static Future<bool> getVolumePrecisionMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set whether volume precision mode is enabled
|
||||||
|
static Future<void> setVolumePrecisionMode(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyVolumePrecisionMode, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||||
// Stored as JSON: {"podcastId": "itunesUrl", ...}
|
// Stored as JSON: {"podcastId": "itunesUrl", ...}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../models/media_item.dart';
|
|||||||
import 'database_service.dart';
|
import 'database_service.dart';
|
||||||
import 'debug_logger.dart';
|
import 'debug_logger.dart';
|
||||||
import 'music_assistant_api.dart';
|
import 'music_assistant_api.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
/// Sync status for UI indicators
|
/// Sync status for UI indicators
|
||||||
enum SyncStatus {
|
enum SyncStatus {
|
||||||
@@ -150,10 +151,14 @@ class SyncService with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
_logger.log('🔄 Starting background library sync...');
|
_logger.log('🔄 Starting background library sync...');
|
||||||
|
|
||||||
|
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||||
|
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||||
|
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||||
|
|
||||||
// Fetch fresh data from MA API (in parallel for speed)
|
// Fetch fresh data from MA API (in parallel for speed)
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
api.getAlbums(limit: 1000),
|
api.getAlbums(limit: 1000),
|
||||||
api.getArtists(limit: 1000),
|
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||||
api.getAudiobooks(limit: 1000),
|
api.getAudiobooks(limit: 1000),
|
||||||
api.getPlaylists(limit: 1000),
|
api.getPlaylists(limit: 1000),
|
||||||
]);
|
]);
|
||||||
@@ -166,6 +171,12 @@ class SyncService with ChangeNotifier {
|
|||||||
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
|
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
|
||||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
|
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
|
||||||
|
|
||||||
|
// Clear old cache before saving fresh data (removes stale items)
|
||||||
|
await _db.clearCacheForType('album');
|
||||||
|
await _db.clearCacheForType('artist');
|
||||||
|
await _db.clearCacheForType('audiobook');
|
||||||
|
await _db.clearCacheForType('playlist');
|
||||||
|
|
||||||
// Save to database cache
|
// Save to database cache
|
||||||
await _saveAlbumsToCache(albums);
|
await _saveAlbumsToCache(albums);
|
||||||
await _saveArtistsToCache(artists);
|
await _saveArtistsToCache(artists);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Get the current adaptive surface color (for bottom nav background, etc.)
|
/// Get the current adaptive surface color (for bottom nav background, etc.)
|
||||||
/// Returns a subtle tinted surface based on the adaptive colors
|
/// Returns a subtle tinted surface based on the adaptive colors
|
||||||
|
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
|
||||||
Color? get adaptiveSurfaceColor {
|
Color? get adaptiveSurfaceColor {
|
||||||
if (_adaptiveColors == null) return null;
|
if (_adaptiveColors == null) return null;
|
||||||
// Use the miniPlayer color but darkened for a subtle tinted background
|
// Use the miniPlayer color but darkened for a subtle tinted background
|
||||||
@@ -68,6 +69,20 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
|
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get adaptive surface color for the given brightness (light/dark mode aware)
|
||||||
|
/// Returns the same color as the expanded player background (scheme.surface)
|
||||||
|
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
|
||||||
|
// Pick scheme based on current mode
|
||||||
|
final scheme = brightness == Brightness.dark
|
||||||
|
? _adaptiveDarkScheme
|
||||||
|
: _adaptiveLightScheme;
|
||||||
|
|
||||||
|
if (scheme == null) return null;
|
||||||
|
|
||||||
|
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
|
||||||
|
return scheme.surface;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final themeModeString = await SettingsService.getThemeMode();
|
final themeModeString = await SettingsService.getThemeMode();
|
||||||
_themeMode = _parseThemeMode(themeModeString);
|
_themeMode = _parseThemeMode(themeModeString);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// Fuzzy string matching using Jaro-Winkler similarity.
|
||||||
|
///
|
||||||
|
/// Jaro-Winkler is optimized for short strings like artist/song names
|
||||||
|
/// and gives extra weight to matching prefixes (common typos occur mid-word).
|
||||||
|
class FuzzyMatcher {
|
||||||
|
/// Calculate Jaro-Winkler similarity between two strings.
|
||||||
|
///
|
||||||
|
/// Returns a value between 0.0 (no similarity) and 1.0 (exact match).
|
||||||
|
///
|
||||||
|
/// Jaro-Winkler gives bonus weight to strings that match from the beginning,
|
||||||
|
/// which is useful for handling typos like "Beetles" vs "Beatles".
|
||||||
|
double jaroWinklerSimilarity(String s1, String s2) {
|
||||||
|
if (s1 == s2) return 1.0;
|
||||||
|
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final jaro = _jaroSimilarity(s1, s2);
|
||||||
|
|
||||||
|
// Winkler modification: boost score for common prefix
|
||||||
|
int prefixLength = 0;
|
||||||
|
const maxPrefix = 4; // Standard Winkler prefix length
|
||||||
|
final minLen = min(s1.length, s2.length);
|
||||||
|
final prefixLimit = min(maxPrefix, minLen);
|
||||||
|
|
||||||
|
for (int i = 0; i < prefixLimit; i++) {
|
||||||
|
if (s1[i] == s2[i]) {
|
||||||
|
prefixLength++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalingFactor = 0.1; // Standard Winkler scaling factor
|
||||||
|
return jaro + (prefixLength * scalingFactor * (1 - jaro));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate base Jaro similarity.
|
||||||
|
double _jaroSimilarity(String s1, String s2) {
|
||||||
|
final s1Len = s1.length;
|
||||||
|
final s2Len = s2.length;
|
||||||
|
|
||||||
|
// Match window: characters within this distance can match
|
||||||
|
final matchWindow = (max(s1Len, s2Len) / 2 - 1).floor();
|
||||||
|
if (matchWindow < 0) return 0.0;
|
||||||
|
|
||||||
|
final s1Matches = List.filled(s1Len, false);
|
||||||
|
final s2Matches = List.filled(s2Len, false);
|
||||||
|
|
||||||
|
int matches = 0;
|
||||||
|
int transpositions = 0;
|
||||||
|
|
||||||
|
// Find matching characters
|
||||||
|
for (int i = 0; i < s1Len; i++) {
|
||||||
|
final start = max(0, i - matchWindow);
|
||||||
|
final end = min(s2Len, i + matchWindow + 1);
|
||||||
|
|
||||||
|
for (int j = start; j < end; j++) {
|
||||||
|
if (s2Matches[j] || s1[i] != s2[j]) continue;
|
||||||
|
s1Matches[i] = true;
|
||||||
|
s2Matches[j] = true;
|
||||||
|
matches++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches == 0) return 0.0;
|
||||||
|
|
||||||
|
// Count transpositions
|
||||||
|
int k = 0;
|
||||||
|
for (int i = 0; i < s1Len; i++) {
|
||||||
|
if (!s1Matches[i]) continue;
|
||||||
|
while (!s2Matches[k]) {
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
if (s1[i] != s2[k]) transpositions++;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jaro similarity formula
|
||||||
|
return (matches / s1Len +
|
||||||
|
matches / s2Len +
|
||||||
|
(matches - transpositions / 2) / matches) /
|
||||||
|
3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the best match score between query tokens and text tokens.
|
||||||
|
///
|
||||||
|
/// Useful for matching individual words when full string matching fails.
|
||||||
|
/// Returns the highest similarity score found between any token pair.
|
||||||
|
double bestTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||||
|
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
double bestScore = 0.0;
|
||||||
|
|
||||||
|
for (final queryToken in queryTokens) {
|
||||||
|
for (final textToken in textTokens) {
|
||||||
|
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
// Early exit if we find a perfect match
|
||||||
|
if (score == 1.0) return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if two strings are a fuzzy match within a threshold.
|
||||||
|
bool isFuzzyMatch(String s1, String s2, {double threshold = 0.85}) {
|
||||||
|
return jaroWinklerSimilarity(s1, s2) >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate average token match score.
|
||||||
|
///
|
||||||
|
/// For each query token, finds the best matching text token,
|
||||||
|
/// then averages all best matches. Useful for multi-word queries.
|
||||||
|
double averageTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||||
|
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
double totalScore = 0.0;
|
||||||
|
|
||||||
|
for (final queryToken in queryTokens) {
|
||||||
|
double bestForToken = 0.0;
|
||||||
|
for (final textToken in textTokens) {
|
||||||
|
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||||
|
if (score > bestForToken) {
|
||||||
|
bestForToken = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalScore += bestForToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalScore / queryTokens.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/// N-gram based string matching for partial matches.
|
||||||
|
///
|
||||||
|
/// Uses character bigrams (2-grams) to find partial string matches
|
||||||
|
/// when exact and fuzzy matching fail.
|
||||||
|
class NgramMatcher {
|
||||||
|
/// Generate character bigrams from text.
|
||||||
|
///
|
||||||
|
/// Example: "hello" -> ["he", "el", "ll", "lo"]
|
||||||
|
///
|
||||||
|
/// For strings shorter than 2 characters, returns the string itself.
|
||||||
|
List<String> generateBigrams(String text) {
|
||||||
|
if (text.length < 2) return text.isNotEmpty ? [text] : [];
|
||||||
|
|
||||||
|
final bigrams = <String>[];
|
||||||
|
for (int i = 0; i < text.length - 1; i++) {
|
||||||
|
bigrams.add(text.substring(i, i + 2));
|
||||||
|
}
|
||||||
|
return bigrams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate character trigrams from text.
|
||||||
|
///
|
||||||
|
/// Example: "hello" -> ["hel", "ell", "llo"]
|
||||||
|
List<String> generateTrigrams(String text) {
|
||||||
|
if (text.length < 3) return text.isNotEmpty ? [text] : [];
|
||||||
|
|
||||||
|
final trigrams = <String>[];
|
||||||
|
for (int i = 0; i < text.length - 2; i++) {
|
||||||
|
trigrams.add(text.substring(i, i + 3));
|
||||||
|
}
|
||||||
|
return trigrams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate bigram similarity using Dice coefficient.
|
||||||
|
///
|
||||||
|
/// Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
|
||||||
|
/// Returns a value between 0.0 and 1.0.
|
||||||
|
double bigramSimilarity(String s1, String s2) {
|
||||||
|
final lower1 = s1.toLowerCase();
|
||||||
|
final lower2 = s2.toLowerCase();
|
||||||
|
|
||||||
|
final bigrams1 = generateBigrams(lower1);
|
||||||
|
final bigrams2 = generateBigrams(lower2);
|
||||||
|
|
||||||
|
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final set1 = bigrams1.toSet();
|
||||||
|
final set2 = bigrams2.toSet();
|
||||||
|
|
||||||
|
final intersection = set1.intersection(set2).length;
|
||||||
|
|
||||||
|
// Dice coefficient
|
||||||
|
return (2 * intersection) / (set1.length + set2.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Jaccard similarity between bigram sets.
|
||||||
|
///
|
||||||
|
/// Jaccard: |intersection| / |union|
|
||||||
|
/// More conservative than Dice coefficient.
|
||||||
|
double jaccardSimilarity(String s1, String s2) {
|
||||||
|
final lower1 = s1.toLowerCase();
|
||||||
|
final lower2 = s2.toLowerCase();
|
||||||
|
|
||||||
|
final bigrams1 = generateBigrams(lower1).toSet();
|
||||||
|
final bigrams2 = generateBigrams(lower2).toSet();
|
||||||
|
|
||||||
|
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final intersection = bigrams1.intersection(bigrams2).length;
|
||||||
|
final union = bigrams1.union(bigrams2).length;
|
||||||
|
|
||||||
|
return intersection / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if query partially matches text via bigrams.
|
||||||
|
///
|
||||||
|
/// Uses Dice coefficient with configurable threshold.
|
||||||
|
bool hasPartialMatch(String query, String text, {double threshold = 0.5}) {
|
||||||
|
return bigramSimilarity(query, text) >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate containment coefficient.
|
||||||
|
///
|
||||||
|
/// Measures how many of the query's bigrams are in the text.
|
||||||
|
/// Useful when query is expected to be shorter than text.
|
||||||
|
/// Returns |intersection| / |query bigrams|
|
||||||
|
double containmentScore(String query, String text) {
|
||||||
|
final queryBigrams = generateBigrams(query.toLowerCase()).toSet();
|
||||||
|
final textBigrams = generateBigrams(text.toLowerCase()).toSet();
|
||||||
|
|
||||||
|
if (queryBigrams.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final intersection = queryBigrams.intersection(textBigrams).length;
|
||||||
|
return intersection / queryBigrams.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/// Configuration for search scoring weights and thresholds.
|
||||||
|
///
|
||||||
|
/// All scores are designed to work with the existing sorting system
|
||||||
|
/// where higher scores indicate better matches.
|
||||||
|
class ScoringConfig {
|
||||||
|
// Base scores for match types (primary name matching)
|
||||||
|
final double exactMatch;
|
||||||
|
final double exactMatchNoStopwords;
|
||||||
|
final double startsWithMatch;
|
||||||
|
final double startsWithNoStopwords;
|
||||||
|
final double wordBoundaryMatch;
|
||||||
|
final double wordBoundaryNoStopwords;
|
||||||
|
final double reverseContainsMatch;
|
||||||
|
final double reverseContainsNoStopwords;
|
||||||
|
final double containsMatch;
|
||||||
|
final double containsNoStopwords;
|
||||||
|
final double fuzzyMatchHigh;
|
||||||
|
final double fuzzyMatchMedium;
|
||||||
|
final double ngramMatch;
|
||||||
|
final double baseline;
|
||||||
|
|
||||||
|
// Bonuses (additive)
|
||||||
|
final double libraryBonus;
|
||||||
|
final double favoriteBonus;
|
||||||
|
final double artistFieldExactBonus;
|
||||||
|
final double artistFieldPartialBonus;
|
||||||
|
final double albumFieldBonus;
|
||||||
|
final double authorFieldExactBonus;
|
||||||
|
final double authorFieldPartialBonus;
|
||||||
|
final double narratorFieldBonus;
|
||||||
|
final double creatorFieldExactBonus;
|
||||||
|
final double creatorFieldPartialBonus;
|
||||||
|
final double descriptionBonus;
|
||||||
|
|
||||||
|
// Thresholds
|
||||||
|
final double fuzzyHighThreshold;
|
||||||
|
final double fuzzyMediumThreshold;
|
||||||
|
final double ngramThreshold;
|
||||||
|
|
||||||
|
// Minimum lengths for matching
|
||||||
|
final int minReverseMatchLength;
|
||||||
|
final int minTokenLength;
|
||||||
|
|
||||||
|
const ScoringConfig({
|
||||||
|
// Primary match scores
|
||||||
|
this.exactMatch = 100,
|
||||||
|
this.exactMatchNoStopwords = 95,
|
||||||
|
this.startsWithMatch = 85,
|
||||||
|
this.startsWithNoStopwords = 80,
|
||||||
|
this.wordBoundaryMatch = 70,
|
||||||
|
this.wordBoundaryNoStopwords = 65,
|
||||||
|
this.reverseContainsMatch = 60,
|
||||||
|
this.reverseContainsNoStopwords = 55,
|
||||||
|
this.containsMatch = 50,
|
||||||
|
this.containsNoStopwords = 45,
|
||||||
|
this.fuzzyMatchHigh = 40,
|
||||||
|
this.fuzzyMatchMedium = 35,
|
||||||
|
this.ngramMatch = 25,
|
||||||
|
this.baseline = 20,
|
||||||
|
|
||||||
|
// Bonuses
|
||||||
|
this.libraryBonus = 10,
|
||||||
|
this.favoriteBonus = 5,
|
||||||
|
this.artistFieldExactBonus = 15,
|
||||||
|
this.artistFieldPartialBonus = 8,
|
||||||
|
this.albumFieldBonus = 5,
|
||||||
|
this.authorFieldExactBonus = 15,
|
||||||
|
this.authorFieldPartialBonus = 8,
|
||||||
|
this.narratorFieldBonus = 5,
|
||||||
|
this.creatorFieldExactBonus = 15,
|
||||||
|
this.creatorFieldPartialBonus = 8,
|
||||||
|
this.descriptionBonus = 5,
|
||||||
|
|
||||||
|
// Thresholds
|
||||||
|
this.fuzzyHighThreshold = 0.90,
|
||||||
|
this.fuzzyMediumThreshold = 0.85,
|
||||||
|
this.ngramThreshold = 0.5,
|
||||||
|
|
||||||
|
// Minimum lengths
|
||||||
|
this.minReverseMatchLength = 3,
|
||||||
|
this.minTokenLength = 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Default configuration
|
||||||
|
static const ScoringConfig defaults = ScoringConfig();
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/// Search scoring system for ranking search results.
|
||||||
|
///
|
||||||
|
/// Provides advanced text matching including:
|
||||||
|
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||||
|
/// - Fuzzy matching (typo tolerance)
|
||||||
|
/// - N-gram matching (partial matches)
|
||||||
|
/// - Reverse matching (query contains result)
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'scoring_config.dart';
|
||||||
|
export 'text_normalizer.dart';
|
||||||
|
export 'fuzzy_matcher.dart';
|
||||||
|
export 'ngram_matcher.dart';
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import '../../models/media_item.dart';
|
||||||
|
import 'scoring_config.dart';
|
||||||
|
import 'text_normalizer.dart';
|
||||||
|
import 'fuzzy_matcher.dart';
|
||||||
|
import 'ngram_matcher.dart';
|
||||||
|
|
||||||
|
/// Main search scorer that combines all matching strategies.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// final scorer = SearchScorer();
|
||||||
|
/// final score = scorer.scoreItem(mediaItem, query);
|
||||||
|
/// ```
|
||||||
|
class SearchScorer {
|
||||||
|
final TextNormalizer _normalizer;
|
||||||
|
final FuzzyMatcher _fuzzyMatcher;
|
||||||
|
final NgramMatcher _ngramMatcher;
|
||||||
|
final ScoringConfig _config;
|
||||||
|
|
||||||
|
// Cache for normalized query (reused across items in same search)
|
||||||
|
String? _cachedQueryString;
|
||||||
|
NormalizedQuery? _cachedQuery;
|
||||||
|
|
||||||
|
SearchScorer({ScoringConfig? config})
|
||||||
|
: _normalizer = TextNormalizer(),
|
||||||
|
_fuzzyMatcher = FuzzyMatcher(),
|
||||||
|
_ngramMatcher = NgramMatcher(),
|
||||||
|
_config = config ?? ScoringConfig.defaults;
|
||||||
|
|
||||||
|
/// Access to the normalizer for external use
|
||||||
|
TextNormalizer get normalizer => _normalizer;
|
||||||
|
|
||||||
|
/// Clear the query cache (call when starting a new search)
|
||||||
|
void clearCache() {
|
||||||
|
_cachedQueryString = null;
|
||||||
|
_cachedQuery = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a media item against a search query.
|
||||||
|
///
|
||||||
|
/// Returns a score where higher values indicate better matches.
|
||||||
|
/// Score components:
|
||||||
|
/// - Primary name matching (0-100)
|
||||||
|
/// - Secondary field bonuses (0-20)
|
||||||
|
/// - Library/favorite bonuses (0-15)
|
||||||
|
double scoreItem(MediaItem item, String query) {
|
||||||
|
// Use cached normalized query if available
|
||||||
|
if (_cachedQueryString != query) {
|
||||||
|
_cachedQueryString = query;
|
||||||
|
_cachedQuery = _normalizer.normalizeQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
final nq = _cachedQuery!;
|
||||||
|
if (nq.isEmpty) return 0;
|
||||||
|
|
||||||
|
final nameLower = item.name.toLowerCase();
|
||||||
|
final nameNoStopwords = _normalizer.normalizeTextNoStopwords(item.name);
|
||||||
|
|
||||||
|
// Calculate primary score based on name matching
|
||||||
|
double score = _calculatePrimaryScore(nameLower, nameNoStopwords, nq);
|
||||||
|
|
||||||
|
// Add secondary field bonuses based on media type
|
||||||
|
score += _calculateSecondaryScore(item, nq);
|
||||||
|
|
||||||
|
// Add library/favorite bonuses
|
||||||
|
score += _calculateBonuses(item);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate primary score based on name matching.
|
||||||
|
double _calculatePrimaryScore(
|
||||||
|
String nameLower,
|
||||||
|
String nameNoStopwords,
|
||||||
|
NormalizedQuery nq,
|
||||||
|
) {
|
||||||
|
// Tier 1: Exact match (highest priority)
|
||||||
|
if (nameLower == nq.normalized) {
|
||||||
|
return _config.exactMatch;
|
||||||
|
}
|
||||||
|
if (nameNoStopwords == nq.withoutStopwords) {
|
||||||
|
return _config.exactMatchNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: Starts with
|
||||||
|
if (nameLower.startsWith(nq.normalized)) {
|
||||||
|
return _config.startsWithMatch;
|
||||||
|
}
|
||||||
|
if (nameNoStopwords.startsWith(nq.withoutStopwords)) {
|
||||||
|
return _config.startsWithNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: Word boundary match
|
||||||
|
if (_matchesWordBoundary(nameLower, nq.normalized)) {
|
||||||
|
return _config.wordBoundaryMatch;
|
||||||
|
}
|
||||||
|
if (_matchesWordBoundary(nameNoStopwords, nq.withoutStopwords)) {
|
||||||
|
return _config.wordBoundaryNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 4: Reverse contains (result name IN query)
|
||||||
|
// This solves "the ramones" finding "Ramones"
|
||||||
|
if (_reverseContains(nq.normalized, nameLower)) {
|
||||||
|
return _config.reverseContainsMatch;
|
||||||
|
}
|
||||||
|
if (_reverseContains(nq.withoutStopwords, nameNoStopwords)) {
|
||||||
|
return _config.reverseContainsNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 5: Contains anywhere
|
||||||
|
if (nameLower.contains(nq.normalized)) {
|
||||||
|
return _config.containsMatch;
|
||||||
|
}
|
||||||
|
if (nameNoStopwords.contains(nq.withoutStopwords)) {
|
||||||
|
return _config.containsNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 6: Fuzzy matching (typo tolerance)
|
||||||
|
final fuzzyScore = _fuzzyMatcher.jaroWinklerSimilarity(
|
||||||
|
nq.withoutStopwords,
|
||||||
|
nameNoStopwords,
|
||||||
|
);
|
||||||
|
if (fuzzyScore >= _config.fuzzyHighThreshold) {
|
||||||
|
// Scale score based on similarity (0.90-1.0 -> 40-45)
|
||||||
|
return _config.fuzzyMatchHigh +
|
||||||
|
((fuzzyScore - _config.fuzzyHighThreshold) * 50);
|
||||||
|
}
|
||||||
|
if (fuzzyScore >= _config.fuzzyMediumThreshold) {
|
||||||
|
return _config.fuzzyMatchMedium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 7: Token-level fuzzy (individual words)
|
||||||
|
final tokenFuzzy = _fuzzyMatcher.bestTokenMatch(
|
||||||
|
nq.tokensNoStop,
|
||||||
|
_normalizer.tokenize(nameNoStopwords),
|
||||||
|
);
|
||||||
|
if (tokenFuzzy >= _config.fuzzyHighThreshold) {
|
||||||
|
return _config.fuzzyMatchMedium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 8: N-gram partial matching
|
||||||
|
final ngramScore = _ngramMatcher.bigramSimilarity(
|
||||||
|
nq.withoutStopwords,
|
||||||
|
nameNoStopwords,
|
||||||
|
);
|
||||||
|
if (ngramScore >= _config.ngramThreshold) {
|
||||||
|
// Scale: 0.5-1.0 -> 25-35
|
||||||
|
return _config.ngramMatch + (ngramScore * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline: Music Assistant API returned it, so some relevance
|
||||||
|
return _config.baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if query matches at word boundary in text.
|
||||||
|
bool _matchesWordBoundary(String text, String query) {
|
||||||
|
if (query.contains(' ')) {
|
||||||
|
// Multi-word query: check if at start or after space
|
||||||
|
if (text.startsWith(query)) return true;
|
||||||
|
if (text.contains(' $query')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-word query: check if any word starts with query
|
||||||
|
final words = text.split(RegExp(r'\s+'));
|
||||||
|
for (final word in words) {
|
||||||
|
if (word.startsWith(query)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if text is contained within query (reverse matching).
|
||||||
|
///
|
||||||
|
/// Solves: "the ramones" query finding "Ramones" artist
|
||||||
|
bool _reverseContains(String query, String text) {
|
||||||
|
if (text.length < _config.minReverseMatchLength) return false;
|
||||||
|
|
||||||
|
// Direct containment
|
||||||
|
if (query.contains(text)) return true;
|
||||||
|
|
||||||
|
// Token-level: any query token equals text
|
||||||
|
final queryTokens = query.split(RegExp(r'\s+'));
|
||||||
|
for (final token in queryTokens) {
|
||||||
|
if (token.length >= _config.minReverseMatchLength && text == token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate secondary field bonuses based on media type.
|
||||||
|
double _calculateSecondaryScore(MediaItem item, NormalizedQuery nq) {
|
||||||
|
double bonus = 0;
|
||||||
|
|
||||||
|
if (item is Album) {
|
||||||
|
bonus += _scoreArtistField(item.artistsString, nq);
|
||||||
|
} else if (item is Track) {
|
||||||
|
bonus += _scoreArtistField(item.artistsString, nq);
|
||||||
|
// Also check album name
|
||||||
|
if (item.album?.name != null) {
|
||||||
|
final albumLower = item.album!.name.toLowerCase();
|
||||||
|
if (albumLower.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.albumFieldBonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item is Audiobook) {
|
||||||
|
// Check authors
|
||||||
|
final authorLower = item.authorsString.toLowerCase();
|
||||||
|
if (authorLower == nq.withoutStopwords) {
|
||||||
|
bonus += _config.authorFieldExactBonus;
|
||||||
|
} else if (authorLower.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.authorFieldPartialBonus;
|
||||||
|
}
|
||||||
|
// Check narrators
|
||||||
|
final narratorLower = item.narratorsString.toLowerCase();
|
||||||
|
if (narratorLower.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.narratorFieldBonus;
|
||||||
|
}
|
||||||
|
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||||
|
bonus += _scorePodcastFields(item, nq);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score artist field for albums and tracks.
|
||||||
|
double _scoreArtistField(String artistsString, NormalizedQuery nq) {
|
||||||
|
final artistLower = artistsString.toLowerCase();
|
||||||
|
if (artistLower == nq.withoutStopwords) {
|
||||||
|
return _config.artistFieldExactBonus;
|
||||||
|
}
|
||||||
|
if (artistLower.contains(nq.withoutStopwords)) {
|
||||||
|
return _config.artistFieldPartialBonus;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score podcast-specific fields (creator, description).
|
||||||
|
double _scorePodcastFields(MediaItem item, NormalizedQuery nq) {
|
||||||
|
double bonus = 0;
|
||||||
|
final metadata = item.metadata;
|
||||||
|
|
||||||
|
if (metadata != null) {
|
||||||
|
// Check creator fields
|
||||||
|
final creatorFields = [
|
||||||
|
metadata['author'] as String?,
|
||||||
|
metadata['publisher'] as String?,
|
||||||
|
metadata['owner'] as String?,
|
||||||
|
metadata['creator'] as String?,
|
||||||
|
].where((s) => s != null && s.isNotEmpty).map((s) => s!.toLowerCase());
|
||||||
|
|
||||||
|
bool foundExact = false;
|
||||||
|
bool foundContains = false;
|
||||||
|
for (final field in creatorFields) {
|
||||||
|
if (field == nq.withoutStopwords) {
|
||||||
|
foundExact = true;
|
||||||
|
break;
|
||||||
|
} else if (field.contains(nq.withoutStopwords)) {
|
||||||
|
foundContains = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundExact) {
|
||||||
|
bonus += _config.creatorFieldExactBonus;
|
||||||
|
} else if (foundContains) {
|
||||||
|
bonus += _config.creatorFieldPartialBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check description
|
||||||
|
final description =
|
||||||
|
(metadata['description'] as String? ?? '').toLowerCase();
|
||||||
|
if (description.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.descriptionBonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: podcast name prominence check
|
||||||
|
if (bonus == 0) {
|
||||||
|
final nameLower = item.name.toLowerCase();
|
||||||
|
if (nameLower.contains(nq.withoutStopwords)) {
|
||||||
|
if (nq.withoutStopwords.contains(' ')) {
|
||||||
|
// Multi-word query prominence
|
||||||
|
final prominence = nq.withoutStopwords.length / nameLower.length;
|
||||||
|
if (prominence >= 0.5) {
|
||||||
|
bonus += _config.creatorFieldExactBonus;
|
||||||
|
} else if (prominence >= 0.3) {
|
||||||
|
bonus += _config.creatorFieldPartialBonus + 4;
|
||||||
|
} else {
|
||||||
|
bonus += _config.creatorFieldPartialBonus;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bonus += _config.descriptionBonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate library and favorite bonuses.
|
||||||
|
double _calculateBonuses(MediaItem item) {
|
||||||
|
double bonus = 0;
|
||||||
|
|
||||||
|
// Library bonus (Album has inLibrary property)
|
||||||
|
if (item is Album && item.inLibrary) {
|
||||||
|
bonus += _config.libraryBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorite bonus
|
||||||
|
if (item.favorite == true) {
|
||||||
|
bonus += _config.favoriteBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/// Text normalization utilities for search scoring.
|
||||||
|
///
|
||||||
|
/// Handles query normalization, stopword removal, and tokenization.
|
||||||
|
|
||||||
|
/// Represents a normalized search query with various forms for matching.
|
||||||
|
class NormalizedQuery {
|
||||||
|
/// Original query as entered by user
|
||||||
|
final String original;
|
||||||
|
|
||||||
|
/// Lowercased and trimmed query
|
||||||
|
final String normalized;
|
||||||
|
|
||||||
|
/// Query with stopwords removed
|
||||||
|
final String withoutStopwords;
|
||||||
|
|
||||||
|
/// Individual tokens from normalized query
|
||||||
|
final List<String> tokens;
|
||||||
|
|
||||||
|
/// Tokens with stopwords removed
|
||||||
|
final List<String> tokensNoStop;
|
||||||
|
|
||||||
|
const NormalizedQuery({
|
||||||
|
required this.original,
|
||||||
|
required this.normalized,
|
||||||
|
required this.withoutStopwords,
|
||||||
|
required this.tokens,
|
||||||
|
required this.tokensNoStop,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether the query is empty after normalization
|
||||||
|
bool get isEmpty => normalized.isEmpty;
|
||||||
|
|
||||||
|
/// Whether stopword removal changed the query
|
||||||
|
bool get hasStopwordsRemoved => normalized != withoutStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes text and queries for search matching.
|
||||||
|
class TextNormalizer {
|
||||||
|
/// Common English stopwords relevant to music/media searches.
|
||||||
|
///
|
||||||
|
/// Intentionally minimal to avoid over-filtering:
|
||||||
|
/// - Articles that commonly prefix artist/album names
|
||||||
|
/// - Common prepositions that add noise
|
||||||
|
static const Set<String> _stopwords = {
|
||||||
|
// Articles
|
||||||
|
'the',
|
||||||
|
'a',
|
||||||
|
'an',
|
||||||
|
// Common prepositions in music/media context
|
||||||
|
'of',
|
||||||
|
'and',
|
||||||
|
'in',
|
||||||
|
'on',
|
||||||
|
'at',
|
||||||
|
'to',
|
||||||
|
'for',
|
||||||
|
'with',
|
||||||
|
'by',
|
||||||
|
// Common noise words
|
||||||
|
'is',
|
||||||
|
'are',
|
||||||
|
'was',
|
||||||
|
'be',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Normalize a search query into multiple forms for matching.
|
||||||
|
///
|
||||||
|
/// Returns a [NormalizedQuery] containing:
|
||||||
|
/// - Original query
|
||||||
|
/// - Lowercased/trimmed version
|
||||||
|
/// - Version with stopwords removed
|
||||||
|
/// - Tokenized versions
|
||||||
|
NormalizedQuery normalizeQuery(String query) {
|
||||||
|
final normalized = query.toLowerCase().trim();
|
||||||
|
final tokens = tokenize(normalized);
|
||||||
|
final tokensNoStop = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||||
|
|
||||||
|
// If all tokens were stopwords, preserve original tokens
|
||||||
|
// e.g., "The The" (band name) shouldn't become empty
|
||||||
|
final effectiveTokensNoStop =
|
||||||
|
tokensNoStop.isEmpty ? tokens : tokensNoStop;
|
||||||
|
final withoutStopwords = effectiveTokensNoStop.join(' ');
|
||||||
|
|
||||||
|
return NormalizedQuery(
|
||||||
|
original: query,
|
||||||
|
normalized: normalized,
|
||||||
|
withoutStopwords: withoutStopwords,
|
||||||
|
tokens: tokens,
|
||||||
|
tokensNoStop: effectiveTokensNoStop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize text for comparison (lowercase, trim).
|
||||||
|
String normalizeText(String text) {
|
||||||
|
return text.toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize text with stopwords removed.
|
||||||
|
String normalizeTextNoStopwords(String text) {
|
||||||
|
final tokens = tokenize(text.toLowerCase().trim());
|
||||||
|
final filtered = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||||
|
return filtered.isEmpty ? text.toLowerCase().trim() : filtered.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split text into tokens on whitespace.
|
||||||
|
List<String> tokenize(String text) {
|
||||||
|
return text.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a word is a stopword.
|
||||||
|
bool isStopword(String word) {
|
||||||
|
return _stopwords.contains(word.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
|||||||
late Future<List<Audiobook>> _audiobooksFuture;
|
late Future<List<Audiobook>> _audiobooksFuture;
|
||||||
List<Audiobook>? _cachedAudiobooks;
|
List<Audiobook>? _cachedAudiobooks;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
bool _hasPrecachedAudiobooks = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -45,12 +46,38 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
|||||||
if (!_hasLoaded) {
|
if (!_hasLoaded) {
|
||||||
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
|
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
|
||||||
_cachedAudiobooks = audiobooks;
|
_cachedAudiobooks = audiobooks;
|
||||||
|
// Pre-cache images for smooth Hero animations
|
||||||
|
_precacheAudiobookImages(audiobooks);
|
||||||
return audiobooks;
|
return audiobooks;
|
||||||
});
|
});
|
||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-cache audiobook images so Hero animations are smooth on first tap
|
||||||
|
void _precacheAudiobookImages(List<Audiobook> audiobooks) {
|
||||||
|
if (!mounted || _hasPrecachedAudiobooks) return;
|
||||||
|
_hasPrecachedAudiobooks = true;
|
||||||
|
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Only precache first ~10 visible items
|
||||||
|
final audiobooksToCache = audiobooks.take(10);
|
||||||
|
|
||||||
|
for (final audiobook in audiobooksToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(audiobook, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) {
|
||||||
|
// Silently ignore precache errors
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+403
-128
@@ -11,6 +11,7 @@ import '../services/animation_debugger.dart';
|
|||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
import 'animated_icon_button.dart';
|
import 'animated_icon_button.dart';
|
||||||
import 'global_player_overlay.dart';
|
import 'global_player_overlay.dart';
|
||||||
import 'volume_control.dart';
|
import 'volume_control.dart';
|
||||||
@@ -76,6 +77,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
||||||
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
||||||
|
|
||||||
|
// Pre-computed static colors to avoid object creation during animation frames
|
||||||
|
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
|
||||||
|
|
||||||
// Dimensions
|
// Dimensions
|
||||||
static double get _collapsedHeight => MiniPlayerLayout.height;
|
static double get _collapsedHeight => MiniPlayerLayout.height;
|
||||||
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
|
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
|
||||||
@@ -92,7 +96,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
// Slide animation for device switching - now supports finger-following
|
// Slide animation for device switching - now supports finger-following
|
||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right
|
// ValueNotifier avoids setState during high-frequency drag updates
|
||||||
|
final ValueNotifier<double> _slideOffsetNotifier = ValueNotifier(0.0);
|
||||||
|
double get _slideOffset => _slideOffsetNotifier.value;
|
||||||
|
set _slideOffset(double value) => _slideOffsetNotifier.value = value;
|
||||||
bool _isSliding = false;
|
bool _isSliding = false;
|
||||||
bool _isDragging = false; // True while finger is actively dragging
|
bool _isDragging = false; // True while finger is actively dragging
|
||||||
|
|
||||||
@@ -108,16 +115,31 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
bool _isDraggingVolume = false;
|
bool _isDraggingVolume = false;
|
||||||
double _dragVolumeLevel = 0.0;
|
double _dragVolumeLevel = 0.0;
|
||||||
int _lastVolumeUpdateTime = 0;
|
int _lastVolumeUpdateTime = 0;
|
||||||
|
int _lastVolumeDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
||||||
|
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
||||||
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||||
|
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
|
||||||
|
|
||||||
|
// Volume precision mode state
|
||||||
|
bool _inVolumePrecisionMode = false;
|
||||||
|
Timer? _volumePrecisionTimer;
|
||||||
|
Offset? _lastVolumeDragPosition;
|
||||||
|
double _lastVolumeLocalX = 0.0; // Last local X position during drag
|
||||||
|
bool _volumePrecisionModeEnabled = true; // From settings
|
||||||
|
double _volumePrecisionZoomCenter = 0.0; // Volume level when precision mode started
|
||||||
|
double _volumePrecisionStartX = 0.0; // Finger X position when precision mode started
|
||||||
|
static const int _precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
|
||||||
|
static const double _precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
|
||||||
|
static const double _precisionSensitivity = 0.1; // Zoomed range (10% = left edge to right edge)
|
||||||
|
|
||||||
// Track favorite state for current track
|
// Track favorite state for current track
|
||||||
bool _isCurrentTrackFavorite = false;
|
bool _isCurrentTrackFavorite = false;
|
||||||
String? _lastTrackUri; // Track which track we last checked favorite status for
|
String? _lastTrackUri; // Track which track we last checked favorite status for
|
||||||
|
|
||||||
// Cached title height to avoid TextPainter.layout() every animation frame
|
// Cached title height to avoid TextPainter.layout() every animation frame
|
||||||
|
// Only invalidate when track name changes (screen width doesn't change during animation)
|
||||||
double? _cachedExpandedTitleHeight;
|
double? _cachedExpandedTitleHeight;
|
||||||
String? _lastMeasuredTrackName;
|
String? _lastMeasuredTrackName;
|
||||||
double? _lastMeasuredTitleWidth;
|
|
||||||
|
|
||||||
// Gesture-driven expansion state
|
// Gesture-driven expansion state
|
||||||
bool _isVerticalDragging = false;
|
bool _isVerticalDragging = false;
|
||||||
@@ -176,6 +198,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_loadQueue();
|
_loadQueue();
|
||||||
} else if (status == AnimationStatus.dismissed) {
|
} else if (status == AnimationStatus.dismissed) {
|
||||||
// Close queue panel when player collapses
|
// Close queue panel when player collapses
|
||||||
|
_queuePanelController.duration = _queueCloseDuration;
|
||||||
_queuePanelController.reverse();
|
_queuePanelController.reverse();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -191,6 +214,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_stopQueueRefreshTimer();
|
_stopQueueRefreshTimer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load precision mode setting
|
||||||
|
_loadVolumePrecisionModeSetting();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVolumePrecisionModeSetting() async {
|
||||||
|
final enabled = await SettingsService.getVolumePrecisionMode();
|
||||||
|
if (mounted) {
|
||||||
|
_volumePrecisionModeEnabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enterVolumePrecisionMode() {
|
||||||
|
if (_inVolumePrecisionMode) return;
|
||||||
|
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
|
||||||
|
setState(() {
|
||||||
|
_inVolumePrecisionMode = true;
|
||||||
|
_volumePrecisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
|
||||||
|
_volumePrecisionStartX = _lastVolumeLocalX; // Capture finger position at entry
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitVolumePrecisionMode() {
|
||||||
|
_volumePrecisionTimer?.cancel();
|
||||||
|
_volumePrecisionTimer = null;
|
||||||
|
if (!_inVolumePrecisionMode) return;
|
||||||
|
setState(() {
|
||||||
|
_inVolumePrecisionMode = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer? _queueRefreshTimer;
|
Timer? _queueRefreshTimer;
|
||||||
@@ -215,8 +267,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_queuePanelController.dispose();
|
_queuePanelController.dispose();
|
||||||
_slideController.dispose();
|
_slideController.dispose();
|
||||||
|
_slideOffsetNotifier.dispose();
|
||||||
_positionSubscription?.cancel();
|
_positionSubscription?.cancel();
|
||||||
_queueRefreshTimer?.cancel();
|
_queueRefreshTimer?.cancel();
|
||||||
|
_volumePrecisionTimer?.cancel();
|
||||||
_progressNotifier.dispose();
|
_progressNotifier.dispose();
|
||||||
_seekPositionNotifier.dispose();
|
_seekPositionNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -229,6 +283,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Animation durations - asymmetric for snappier collapse
|
// Animation durations - asymmetric for snappier collapse
|
||||||
static const Duration _expandDuration = Duration(milliseconds: 280);
|
static const Duration _expandDuration = Duration(milliseconds: 280);
|
||||||
static const Duration _collapseDuration = Duration(milliseconds: 200);
|
static const Duration _collapseDuration = Duration(milliseconds: 200);
|
||||||
|
static const Duration _queueOpenDuration = Duration(milliseconds: 250);
|
||||||
|
static const Duration _queueCloseDuration = Duration(milliseconds: 180);
|
||||||
|
|
||||||
void expand() {
|
void expand() {
|
||||||
if (_isVerticalDragging) return;
|
if (_isVerticalDragging) return;
|
||||||
@@ -241,9 +297,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
void collapse() {
|
void collapse() {
|
||||||
if (_isVerticalDragging) return;
|
if (_isVerticalDragging) return;
|
||||||
|
// If queue panel is open, close it first instead of collapsing player
|
||||||
|
// This prevents both from closing on a single back press
|
||||||
|
if (isQueuePanelOpen) {
|
||||||
|
closeQueuePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
AnimationDebugger.startSession('playerCollapse');
|
AnimationDebugger.startSession('playerCollapse');
|
||||||
// Instantly hide queue panel when collapsing to avoid visual glitches
|
// Instantly hide queue panel when collapsing to avoid visual glitches
|
||||||
// during Android's predictive back gesture
|
// during Android's predictive back gesture (only reached if queue already closed)
|
||||||
_queuePanelController.value = 0;
|
_queuePanelController.value = 0;
|
||||||
_controller.duration = _collapseDuration;
|
_controller.duration = _collapseDuration;
|
||||||
_controller.reverse().then((_) {
|
_controller.reverse().then((_) {
|
||||||
@@ -514,14 +576,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
void _toggleQueuePanel() {
|
void _toggleQueuePanel() {
|
||||||
if (_queuePanelController.isAnimating) return;
|
if (_queuePanelController.isAnimating) return;
|
||||||
if (_queuePanelController.value == 0) {
|
if (_queuePanelController.value == 0) {
|
||||||
|
_queuePanelController.duration = _queueOpenDuration;
|
||||||
_queuePanelController.forward();
|
_queuePanelController.forward();
|
||||||
} else {
|
} else {
|
||||||
|
_queuePanelController.duration = _queueCloseDuration;
|
||||||
_queuePanelController.reverse();
|
_queuePanelController.reverse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
||||||
|
|
||||||
|
/// Close queue panel if open (for external access via GlobalPlayerOverlay)
|
||||||
|
void closeQueuePanel() {
|
||||||
|
if (isQueuePanelOpen && !_queuePanelController.isAnimating) {
|
||||||
|
_queuePanelController.duration = _queueCloseDuration;
|
||||||
|
_queuePanelController.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Update favorite status when track changes
|
/// Update favorite status when track changes
|
||||||
void _updateFavoriteStatus(dynamic currentTrack) {
|
void _updateFavoriteStatus(dynamic currentTrack) {
|
||||||
if (currentTrack == null) {
|
if (currentTrack == null) {
|
||||||
@@ -583,9 +655,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
||||||
if (mappings != null && mappings.isNotEmpty) {
|
if (mappings != null && mappings.isNotEmpty) {
|
||||||
// Find a non-library provider mapping
|
// Find a non-library provider mapping
|
||||||
|
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
|
||||||
for (final mapping in mappings) {
|
for (final mapping in mappings) {
|
||||||
if (mapping.available == true && mapping.providerInstance != 'library') {
|
if (mapping.available == true && mapping.providerInstance != 'library') {
|
||||||
actualProvider = mapping.providerInstance;
|
actualProvider = mapping.providerDomain;
|
||||||
actualItemId = mapping.itemId;
|
actualItemId = mapping.itemId;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -594,7 +667,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
if (actualProvider == 'library') {
|
if (actualProvider == 'library') {
|
||||||
for (final mapping in mappings) {
|
for (final mapping in mappings) {
|
||||||
if (mapping.available == true) {
|
if (mapping.available == true) {
|
||||||
actualProvider = mapping.providerInstance;
|
actualProvider = mapping.providerDomain;
|
||||||
actualItemId = mapping.itemId;
|
actualItemId = mapping.itemId;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -770,32 +843,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final normalizedDelta = delta / containerWidth;
|
final normalizedDelta = delta / containerWidth;
|
||||||
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
|
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
|
||||||
|
|
||||||
// Update peek player based on drag direction (must be inside setState to trigger rebuild)
|
// Update slideOffset via ValueNotifier (no setState needed - avoids 60fps rebuilds)
|
||||||
setState(() {
|
_slideOffset = newSlideOffset;
|
||||||
_slideOffset = newSlideOffset;
|
|
||||||
if (_slideOffset != 0) {
|
// Only trigger setState when peek player changes (not every frame)
|
||||||
_updatePeekPlayerState(maProvider, _slideOffset);
|
if (_slideOffset != 0) {
|
||||||
}
|
_updatePeekPlayerState(maProvider, _slideOffset);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update peek player state variables (called inside setState)
|
/// Update peek player state variables - only calls setState when peek player changes
|
||||||
void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) {
|
void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) {
|
||||||
// dragDirection < 0 means swiping left (next player)
|
// dragDirection < 0 means swiping left (next player)
|
||||||
// dragDirection > 0 means swiping right (previous player)
|
// dragDirection > 0 means swiping right (previous player)
|
||||||
final isNext = dragDirection < 0;
|
final isNext = dragDirection < 0;
|
||||||
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
|
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
|
||||||
|
|
||||||
|
// Only setState when peek player actually changes (prevents unnecessary rebuilds)
|
||||||
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
|
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
|
||||||
_peekPlayer = newPeekPlayer;
|
setState(() {
|
||||||
// Get the peek player's current track image if available
|
_peekPlayer = newPeekPlayer;
|
||||||
if (_peekPlayer != null) {
|
// Get the peek player's current track image if available
|
||||||
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
|
if (_peekPlayer != null) {
|
||||||
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
|
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
|
||||||
} else {
|
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
|
||||||
_peekTrack = null;
|
} else {
|
||||||
_peekImageUrl = null;
|
_peekTrack = null;
|
||||||
}
|
_peekImageUrl = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,9 +926,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
void animateToTarget() {
|
void animateToTarget() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
|
final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
|
||||||
setState(() {
|
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
|
||||||
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
|
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_slideController.addListener(animateToTarget);
|
_slideController.addListener(animateToTarget);
|
||||||
@@ -878,8 +953,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Move peek content to center position (slideOffset = 0) while keeping it visible.
|
// Move peek content to center position (slideOffset = 0) while keeping it visible.
|
||||||
// The main content is hidden via _inTransition flag.
|
// The main content is hidden via _inTransition flag.
|
||||||
// This creates a seamless visual - peek content is already at center.
|
// This creates a seamless visual - peek content is already at center.
|
||||||
|
_slideOffset = 0.0; // ValueNotifier triggers AnimatedBuilder rebuild
|
||||||
setState(() {
|
setState(() {
|
||||||
_slideOffset = 0.0;
|
|
||||||
_isSliding = false;
|
_isSliding = false;
|
||||||
// Keep _inTransition = true and peek data intact
|
// Keep _inTransition = true and peek data intact
|
||||||
});
|
});
|
||||||
@@ -973,19 +1048,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
void animateBack() {
|
void animateBack() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final curvedValue = Curves.easeOutBack.transform(_slideController.value);
|
final curvedValue = Curves.easeOutBack.transform(_slideController.value);
|
||||||
setState(() {
|
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
|
||||||
_slideOffset = startOffset * (1.0 - curvedValue);
|
_slideOffset = startOffset * (1.0 - curvedValue);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_slideController.addListener(animateBack);
|
_slideController.addListener(animateBack);
|
||||||
_slideController.duration = const Duration(milliseconds: 300);
|
_slideController.duration = const Duration(milliseconds: 220); // Snappier snap-back
|
||||||
|
|
||||||
_slideController.forward().then((_) {
|
_slideController.forward().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_slideController.removeListener(animateBack);
|
_slideController.removeListener(animateBack);
|
||||||
|
// Update slideOffset via ValueNotifier (triggers rebuild)
|
||||||
|
_slideOffset = 0.0;
|
||||||
|
// Other state changes still need setState
|
||||||
setState(() {
|
setState(() {
|
||||||
_slideOffset = 0.0;
|
|
||||||
_isSliding = false;
|
_isSliding = false;
|
||||||
_peekPlayer = null;
|
_peekPlayer = null;
|
||||||
_peekImageUrl = null;
|
_peekImageUrl = null;
|
||||||
@@ -1039,22 +1115,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return AnimatedBuilder(
|
// Handle Android back button - close queue panel first, then collapse player
|
||||||
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
// Guard against re-entry during animations to prevent double-close
|
||||||
builder: (context, _) {
|
return PopScope(
|
||||||
// If no track is playing, show device selector bar
|
canPop: !isQueuePanelOpen && !isExpanded,
|
||||||
if (currentTrack == null) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
if (!didPop) {
|
||||||
|
// Ignore if already animating (prevents double-processing)
|
||||||
|
if (_queuePanelController.isAnimating || _controller.isAnimating) return;
|
||||||
|
|
||||||
|
if (isQueuePanelOpen) {
|
||||||
|
_toggleQueuePanel();
|
||||||
|
} else if (isExpanded) {
|
||||||
|
collapse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return _buildMorphingPlayer(
|
|
||||||
context,
|
|
||||||
maProvider,
|
|
||||||
selectedPlayer,
|
|
||||||
currentTrack,
|
|
||||||
imageUrl,
|
|
||||||
themeProvider,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
// Include _slideOffsetNotifier to rebuild during finger-following drags
|
||||||
|
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation, _slideOffsetNotifier]),
|
||||||
|
builder: (context, _) {
|
||||||
|
// If no track is playing, show device selector bar
|
||||||
|
if (currentTrack == null) {
|
||||||
|
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
||||||
|
}
|
||||||
|
return _buildMorphingPlayer(
|
||||||
|
context,
|
||||||
|
maProvider,
|
||||||
|
selectedPlayer,
|
||||||
|
currentTrack,
|
||||||
|
imageUrl,
|
||||||
|
themeProvider,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1068,7 +1162,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
ThemeProvider themeProvider,
|
ThemeProvider themeProvider,
|
||||||
) {
|
) {
|
||||||
final screenSize = MediaQuery.of(context).size;
|
final screenSize = MediaQuery.of(context).size;
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
// Use viewPadding to match BottomNavigationBar's height calculation
|
||||||
|
final bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
@@ -1158,7 +1253,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
ThemeProvider themeProvider,
|
ThemeProvider themeProvider,
|
||||||
) {
|
) {
|
||||||
final screenSize = MediaQuery.of(context).size;
|
final screenSize = MediaQuery.of(context).size;
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
// Use viewPadding (not padding) to match BottomNavigationBar's height calculation
|
||||||
|
// viewPadding represents permanent system UI, padding can change (e.g., keyboard)
|
||||||
|
final bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
@@ -1191,16 +1288,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||||
? adaptiveScheme.onPrimaryContainer
|
? adaptiveScheme.onPrimaryContainer
|
||||||
: colorScheme.onPrimaryContainer;
|
: colorScheme.onPrimaryContainer;
|
||||||
final expandedTextColor = adaptiveScheme?.onSurface ?? Colors.white;
|
// Use colorScheme.onSurface as fallback instead of Colors.white for light theme support
|
||||||
|
final expandedTextColor = adaptiveScheme?.onSurface ?? colorScheme.onSurface;
|
||||||
final textColor = Color.lerp(collapsedTextColor, expandedTextColor, t)!;
|
final textColor = Color.lerp(collapsedTextColor, expandedTextColor, t)!;
|
||||||
|
|
||||||
final primaryColor = adaptiveScheme?.primary ?? Colors.white;
|
// Use colorScheme.primary as fallback instead of Colors.white for light theme support
|
||||||
|
final primaryColor = adaptiveScheme?.primary ?? colorScheme.primary;
|
||||||
|
|
||||||
// Always position above bottom nav bar
|
// Always position above bottom nav bar
|
||||||
|
// Overlap by 2px when expanded to eliminate any subpixel rendering gaps
|
||||||
final bottomNavSpace = _bottomNavHeight + bottomPadding;
|
final bottomNavSpace = _bottomNavHeight + bottomPadding;
|
||||||
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
|
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
|
||||||
final expandedBottomOffset = bottomNavSpace;
|
final expandedBottomOffset = bottomNavSpace - 2;
|
||||||
final expandedHeight = screenSize.height - bottomNavSpace;
|
final expandedHeight = screenSize.height - bottomNavSpace + 2;
|
||||||
|
|
||||||
// Apply slide offset to hide mini player (slides down off-screen)
|
// Apply slide offset to hide mini player (slides down off-screen)
|
||||||
// Apply bounce offset for reveal animation (small downward movement)
|
// Apply bounce offset for reveal animation (small downward movement)
|
||||||
@@ -1271,9 +1371,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
);
|
);
|
||||||
// Only recalculate if track name or width changed
|
// Only recalculate when track changes (screen width doesn't change during animation)
|
||||||
if (_lastMeasuredTrackName != currentTrack.name ||
|
if (_lastMeasuredTrackName != currentTrack.name ||
|
||||||
_lastMeasuredTitleWidth != expandedTitleWidth ||
|
|
||||||
_cachedExpandedTitleHeight == null) {
|
_cachedExpandedTitleHeight == null) {
|
||||||
final titlePainter = TextPainter(
|
final titlePainter = TextPainter(
|
||||||
text: TextSpan(text: currentTrack.name, style: titleStyle),
|
text: TextSpan(text: currentTrack.name, style: titleStyle),
|
||||||
@@ -1282,7 +1381,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
)..layout(maxWidth: expandedTitleWidth);
|
)..layout(maxWidth: expandedTitleWidth);
|
||||||
_cachedExpandedTitleHeight = titlePainter.height;
|
_cachedExpandedTitleHeight = titlePainter.height;
|
||||||
_lastMeasuredTrackName = currentTrack.name;
|
_lastMeasuredTrackName = currentTrack.name;
|
||||||
_lastMeasuredTitleWidth = expandedTitleWidth;
|
|
||||||
}
|
}
|
||||||
final expandedTitleHeight = _cachedExpandedTitleHeight!;
|
final expandedTitleHeight = _cachedExpandedTitleHeight!;
|
||||||
|
|
||||||
@@ -1302,7 +1400,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// - Progress bar + times: ~70px
|
// - Progress bar + times: ~70px
|
||||||
// Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding
|
// Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding
|
||||||
// Position progress bar so controls section is anchored at bottom
|
// Position progress bar so controls section is anchored at bottom
|
||||||
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
// Use viewPadding for consistency with player height calculation
|
||||||
|
final bottomSafeArea = MediaQuery.of(context).viewPadding.bottom;
|
||||||
final controlsSectionHeight = 222.0; // Total height of progress + controls + volume
|
final controlsSectionHeight = 222.0; // Total height of progress + controls + volume
|
||||||
final playButtonHalfHeight = 36.0; // Half of the 72px play button container
|
final playButtonHalfHeight = 36.0; // Half of the 72px play button container
|
||||||
final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight;
|
final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight;
|
||||||
@@ -1405,26 +1504,76 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Finish gesture-driven expansion
|
// Finish gesture-driven expansion
|
||||||
_handleVerticalDragEnd(details);
|
_handleVerticalDragEnd(details);
|
||||||
},
|
},
|
||||||
|
onVerticalDragCancel: () {
|
||||||
|
// Reset state if gesture is cancelled (e.g., system takes over)
|
||||||
|
_isVerticalDragging = false;
|
||||||
|
},
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
_horizontalDragStartX = details.globalPosition.dx;
|
_horizontalDragStartX = details.globalPosition.dx;
|
||||||
// Start volume drag if device reveal is visible
|
// Start volume drag if device reveal is visible
|
||||||
if (widget.isDeviceRevealVisible && !isExpanded) {
|
if (widget.isDeviceRevealVisible && !isExpanded) {
|
||||||
final currentVolume = (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0;
|
// For consecutive swipes, use local volume (API may not have updated player state yet)
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final timeSinceLastDrag = now - _lastVolumeDragEndTime;
|
||||||
|
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
|
||||||
|
|
||||||
|
// Use local volume if we have an override AND within window
|
||||||
|
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
|
||||||
|
|
||||||
|
final startVolume = useLocalVolume
|
||||||
|
? _dragVolumeLevel // Continue from where last swipe ended
|
||||||
|
: (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0; // Fresh from player
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingVolume = true;
|
_isDraggingVolume = true;
|
||||||
_dragVolumeLevel = currentVolume;
|
_dragVolumeLevel = startVolume;
|
||||||
});
|
});
|
||||||
|
_lastVolumeDragPosition = details.globalPosition;
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
// Volume swipe when device reveal is visible
|
// Volume swipe when device reveal is visible
|
||||||
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
|
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
|
||||||
// Directional volume: drag right = increase, drag left = decrease
|
// Check for stillness to trigger precision mode (only if enabled in settings)
|
||||||
// Multiple swipes accumulate - each starts from current volume
|
final currentPosition = details.globalPosition;
|
||||||
final dragDelta = details.delta.dx;
|
if (_volumePrecisionModeEnabled && _lastVolumeDragPosition != null) {
|
||||||
final volumeDelta = dragDelta / collapsedWidth;
|
final movement = (currentPosition - _lastVolumeDragPosition!).distance;
|
||||||
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
|
||||||
|
if (movement < _precisionStillnessThreshold) {
|
||||||
|
// Finger is still - start precision timer if not already running
|
||||||
|
if (_volumePrecisionTimer == null && !_inVolumePrecisionMode) {
|
||||||
|
_volumePrecisionTimer = Timer(
|
||||||
|
Duration(milliseconds: _precisionTriggerMs),
|
||||||
|
_enterVolumePrecisionMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Finger moved - cancel timer (but don't exit precision mode if already in it)
|
||||||
|
_volumePrecisionTimer?.cancel();
|
||||||
|
_volumePrecisionTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastVolumeDragPosition = currentPosition;
|
||||||
|
_lastVolumeLocalX = details.localPosition.dx;
|
||||||
|
|
||||||
|
double newVolume;
|
||||||
|
|
||||||
|
if (_inVolumePrecisionMode) {
|
||||||
|
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||||
|
// Full width of movement = precisionSensitivity (10%) change
|
||||||
|
// e.g., at 40% center: moving full width right = 50%, full width left = 30%
|
||||||
|
final offsetX = details.localPosition.dx - _volumePrecisionStartX;
|
||||||
|
final normalizedOffset = offsetX / collapsedWidth; // -1.0 to +1.0 range
|
||||||
|
final volumeChange = normalizedOffset * _precisionSensitivity;
|
||||||
|
newVolume = (_volumePrecisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||||
|
} else {
|
||||||
|
// NORMAL MODE: Delta-based movement (full width = 100%)
|
||||||
|
final dragDelta = details.delta.dx;
|
||||||
|
final volumeDelta = dragDelta / collapsedWidth;
|
||||||
|
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_dragVolumeLevel = newVolume;
|
_dragVolumeLevel = newVolume;
|
||||||
@@ -1456,6 +1605,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
if (_isDraggingVolume) {
|
if (_isDraggingVolume) {
|
||||||
// Send final volume on release
|
// Send final volume on release
|
||||||
maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round());
|
maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round());
|
||||||
|
_lastVolumeDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
|
||||||
|
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
|
||||||
|
_exitVolumePrecisionMode();
|
||||||
|
_lastVolumeDragPosition = null;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingVolume = false;
|
_isDraggingVolume = false;
|
||||||
});
|
});
|
||||||
@@ -1487,6 +1640,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_handleHorizontalDragEnd(details, maProvider);
|
_handleHorizontalDragEnd(details, maProvider);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onHorizontalDragCancel: () {
|
||||||
|
// Reset state if gesture is cancelled (e.g., system takes over)
|
||||||
|
_horizontalDragStartX = null;
|
||||||
|
if (_isDraggingVolume) {
|
||||||
|
_exitVolumePrecisionMode();
|
||||||
|
_lastVolumeDragPosition = null;
|
||||||
|
setState(() => _isDraggingVolume = false);
|
||||||
|
}
|
||||||
|
if (_isDragging) {
|
||||||
|
_isDragging = false;
|
||||||
|
_slideOffset = 0.0;
|
||||||
|
setState(() {
|
||||||
|
_peekPlayer = null;
|
||||||
|
_peekImageUrl = null;
|
||||||
|
_peekTrack = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
// Use foregroundDecoration for border so it renders ON TOP of content
|
// Use foregroundDecoration for border so it renders ON TOP of content
|
||||||
// This prevents the album art from clipping the yellow synced border
|
// This prevents the album art from clipping the yellow synced border
|
||||||
@@ -1500,8 +1671,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
elevation: _lerpDouble(4, 0, t),
|
elevation: _lerpDouble(4, 0, t),
|
||||||
shadowColor: Colors.black.withOpacity(0.3),
|
shadowColor: _shadowColor,
|
||||||
clipBehavior: Clip.antiAlias,
|
// Use hardEdge when fully expanded (no border radius) to avoid anti-alias artifacts
|
||||||
|
// Use antiAlias when collapsed/animating to smooth rounded corners
|
||||||
|
clipBehavior: borderRadius > 0.5 ? Clip.antiAlias : Clip.hardEdge,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -1529,18 +1702,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Fade out as we expand - use color alpha instead of Opacity widget
|
// Fade out as we expand - use color alpha instead of Opacity widget
|
||||||
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
||||||
final progressAreaWidth = width - _collapsedArtSize;
|
final progressAreaWidth = width - _collapsedArtSize;
|
||||||
return ClipRRect(
|
// RepaintBoundary isolates frequent progress updates from parent animation
|
||||||
borderRadius: BorderRadius.only(
|
return RepaintBoundary(
|
||||||
topRight: Radius.circular(borderRadius),
|
child: ClipRRect(
|
||||||
bottomRight: Radius.circular(borderRadius),
|
borderRadius: BorderRadius.only(
|
||||||
),
|
topRight: Radius.circular(borderRadius),
|
||||||
clipBehavior: Clip.hardEdge,
|
bottomRight: Radius.circular(borderRadius),
|
||||||
child: Align(
|
),
|
||||||
alignment: Alignment.centerLeft,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: Container(
|
child: Align(
|
||||||
width: progressAreaWidth * progress,
|
alignment: Alignment.centerLeft,
|
||||||
height: height,
|
child: Container(
|
||||||
color: collapsedBg.withOpacity(progressOpacity),
|
width: progressAreaWidth * progress,
|
||||||
|
height: height,
|
||||||
|
color: collapsedBg.withOpacity(progressOpacity),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1764,7 +1940,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: contentPadding,
|
left: contentPadding,
|
||||||
right: contentPadding,
|
right: contentPadding,
|
||||||
top: expandedAlbumTop + (currentTrack.album != null ? 24 : 0),
|
top: expandedAlbumTop + (maProvider.isPlayingAudiobook ? 24 : (currentTrack.album != null ? 24 : 0)),
|
||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
opacity: _expandAnimation.drive(
|
opacity: _expandAnimation.drive(
|
||||||
Tween(begin: 0.0, end: 1.0).chain(
|
Tween(begin: 0.0, end: 1.0).chain(
|
||||||
@@ -1893,18 +2069,70 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
|
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
|
||||||
top: controlsTop,
|
top: controlsTop,
|
||||||
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
|
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
|
||||||
? _buildAudiobookControls(
|
// When device reveal is visible (player list shown), use compact controls
|
||||||
maProvider: maProvider,
|
// for audiobooks/podcasts: Play, Forward 30, Power
|
||||||
selectedPlayer: selectedPlayer,
|
? widget.isDeviceRevealVisible && t < 0.5
|
||||||
textColor: textColor,
|
? Row(
|
||||||
primaryColor: primaryColor,
|
mainAxisSize: MainAxisSize.min,
|
||||||
backgroundColor: backgroundColor,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
skipButtonSize: skipButtonSize,
|
children: [
|
||||||
playButtonSize: playButtonSize,
|
// Play/Pause - compact like PlayerCard
|
||||||
playButtonContainerSize: playButtonContainerSize,
|
Transform.translate(
|
||||||
t: t,
|
offset: const Offset(3, 0),
|
||||||
expandedElementsOpacity: expandedElementsOpacity,
|
child: SizedBox(
|
||||||
)
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Forward 30 seconds
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(6, 0),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.forward_30_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Power button
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.power_settings_new_rounded,
|
||||||
|
color: selectedPlayer.powered ? textColor : textColor.withOpacity(0.5),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () => maProvider.togglePower(selectedPlayer.playerId),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _buildAudiobookControls(
|
||||||
|
maProvider: maProvider,
|
||||||
|
selectedPlayer: selectedPlayer,
|
||||||
|
textColor: textColor,
|
||||||
|
primaryColor: primaryColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
skipButtonSize: skipButtonSize,
|
||||||
|
playButtonSize: playButtonSize,
|
||||||
|
playButtonContainerSize: playButtonContainerSize,
|
||||||
|
t: t,
|
||||||
|
expandedElementsOpacity: expandedElementsOpacity,
|
||||||
|
)
|
||||||
// When device reveal is visible (player list shown), use compact controls
|
// When device reveal is visible (player list shown), use compact controls
|
||||||
// like PlayerCard: Play, Next, Power - matching other players in the list
|
// like PlayerCard: Play, Next, Power - matching other players in the list
|
||||||
: widget.isDeviceRevealVisible && t < 0.5
|
: widget.isDeviceRevealVisible && t < 0.5
|
||||||
@@ -2058,19 +2286,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
// Favorite button (expanded only) - hide when queue panel is open
|
// Favorite button (expanded only) - hide when queue panel is open
|
||||||
// GPU PERF: Use icon color alpha instead of Opacity
|
// GPU PERF: Use icon color alpha instead of Opacity
|
||||||
|
// Scale pop animation when toggling favorite
|
||||||
if (t > 0.3 && queueT < 0.5)
|
if (t > 0.3 && queueT < 0.5)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topPadding + 4,
|
top: topPadding + 4,
|
||||||
right: 52,
|
right: 52,
|
||||||
child: IconButton(
|
child: TweenAnimationBuilder<double>(
|
||||||
icon: Icon(
|
key: ValueKey(_isCurrentTrackFavorite),
|
||||||
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
|
tween: Tween(begin: 1.3, end: 1.0),
|
||||||
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
|
duration: const Duration(milliseconds: 200),
|
||||||
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
|
curve: Curves.easeOutBack,
|
||||||
size: 24,
|
builder: (context, scale, child) => Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
|
||||||
|
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
),
|
),
|
||||||
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -2153,24 +2392,32 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Volume swipe overlay (covers entire mini player when dragging volume)
|
// Volume swipe overlay (covers entire mini player when dragging volume)
|
||||||
// Only visible when device reveal is open and user is dragging
|
// Only visible when device reveal is open and user is dragging
|
||||||
// Positioned last in Stack so it renders ON TOP of all content
|
// Positioned last in Stack so it renders ON TOP of all content
|
||||||
if (_isDraggingVolume && t < 0.5)
|
// Uses AnimatedOpacity for smooth fade in/out transition
|
||||||
|
if (t < 0.5)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ClipRRect(
|
child: IgnorePointer(
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
ignoring: !_isDraggingVolume,
|
||||||
child: Stack(
|
child: AnimatedOpacity(
|
||||||
children: [
|
opacity: _isDraggingVolume ? 1.0 : 0.0,
|
||||||
// Unfilled (darker) background
|
duration: const Duration(milliseconds: 120),
|
||||||
Container(color: collapsedBgUnplayed),
|
child: ClipRRect(
|
||||||
// Filled (lighter) portion based on volume
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
Align(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
children: [
|
||||||
child: FractionallySizedBox(
|
// Unfilled (darker) background
|
||||||
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
Container(color: collapsedBgUnplayed),
|
||||||
heightFactor: 1.0,
|
// Filled (lighter) portion based on volume
|
||||||
child: Container(color: collapsedBg),
|
Align(
|
||||||
),
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
||||||
|
heightFactor: 1.0,
|
||||||
|
child: Container(color: collapsedBg),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2283,11 +2530,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
||||||
|
// Use theme-aware colors for both collapsed and expanded states
|
||||||
|
final expandedBgColor = colorScheme.surfaceContainerHighest;
|
||||||
|
final expandedIconColor = colorScheme.onSurface.withOpacity(0.24);
|
||||||
return Container(
|
return Container(
|
||||||
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
color: Color.lerp(colorScheme.surfaceVariant, expandedBgColor, t),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.music_note_rounded,
|
Icons.music_note_rounded,
|
||||||
color: Color.lerp(colorScheme.onSurfaceVariant, Colors.white24, t),
|
color: Color.lerp(colorScheme.onSurfaceVariant, expandedIconColor, t),
|
||||||
size: _lerpDouble(24, 120, t),
|
size: _lerpDouble(24, 120, t),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2335,7 +2585,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build audiobook-specific playback controls
|
/// Build audiobook-specific playback controls
|
||||||
/// Layout: [Prev Chapter] [-30s] [Play/Pause] [+30s] [Next Chapter]
|
/// Collapsed: [-30s] [Play/Pause] [+30s]
|
||||||
|
/// Expanded: [Prev Chapter] [-30s] [-10s] [Play/Pause] [+10s] [+30s] [Next Chapter]
|
||||||
Widget _buildAudiobookControls({
|
Widget _buildAudiobookControls({
|
||||||
required MusicAssistantProvider maProvider,
|
required MusicAssistantProvider maProvider,
|
||||||
required dynamic selectedPlayer,
|
required dynamic selectedPlayer,
|
||||||
@@ -2349,19 +2600,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
required double expandedElementsOpacity,
|
required double expandedElementsOpacity,
|
||||||
}) {
|
}) {
|
||||||
final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false;
|
final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false;
|
||||||
|
final isExpanded = t > 0.5;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
|
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// Previous Chapter (expanded only, if chapters available)
|
// Previous Chapter (expanded only, if chapters available)
|
||||||
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
|
if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
|
||||||
_buildSecondaryButton(
|
_buildSecondaryButton(
|
||||||
icon: Icons.skip_previous_rounded,
|
icon: Icons.skip_previous_rounded,
|
||||||
color: textColor.withOpacity(expandedElementsOpacity),
|
color: textColor.withOpacity(expandedElementsOpacity),
|
||||||
onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId),
|
onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId),
|
||||||
),
|
),
|
||||||
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
||||||
|
|
||||||
// Rewind 30 seconds
|
// Rewind 30 seconds
|
||||||
_buildControlButton(
|
_buildControlButton(
|
||||||
@@ -2369,9 +2621,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
color: textColor,
|
color: textColor,
|
||||||
size: skipButtonSize,
|
size: skipButtonSize,
|
||||||
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30),
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30),
|
||||||
useAnimation: t > 0.5,
|
useAnimation: isExpanded,
|
||||||
),
|
),
|
||||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
|
||||||
|
// Rewind 10 seconds (expanded only, same size as 30s)
|
||||||
|
if (isExpanded) ...[
|
||||||
|
SizedBox(width: _lerpDouble(0, 8, t)),
|
||||||
|
_buildControlButton(
|
||||||
|
icon: Icons.replay_10_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: skipButtonSize,
|
||||||
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -10),
|
||||||
|
useAnimation: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SizedBox(width: _lerpDouble(0, 12, t)),
|
||||||
|
|
||||||
// Play/Pause
|
// Play/Pause
|
||||||
_buildPlayButton(
|
_buildPlayButton(
|
||||||
@@ -2385,7 +2649,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
||||||
onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId),
|
onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId),
|
||||||
),
|
),
|
||||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
SizedBox(width: _lerpDouble(0, 12, t)),
|
||||||
|
|
||||||
|
// Forward 10 seconds (expanded only, same size as 30s)
|
||||||
|
if (isExpanded) ...[
|
||||||
|
_buildControlButton(
|
||||||
|
icon: Icons.forward_10_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: skipButtonSize,
|
||||||
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 10),
|
||||||
|
useAnimation: true,
|
||||||
|
),
|
||||||
|
SizedBox(width: _lerpDouble(0, 8, t)),
|
||||||
|
],
|
||||||
|
|
||||||
// Forward 30 seconds
|
// Forward 30 seconds
|
||||||
_buildControlButton(
|
_buildControlButton(
|
||||||
@@ -2393,12 +2669,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
color: textColor,
|
color: textColor,
|
||||||
size: skipButtonSize,
|
size: skipButtonSize,
|
||||||
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
|
||||||
useAnimation: t > 0.5,
|
useAnimation: isExpanded,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Next Chapter (expanded only, if chapters available)
|
// Next Chapter (expanded only, if chapters available)
|
||||||
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
||||||
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
|
if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
|
||||||
_buildSecondaryButton(
|
_buildSecondaryButton(
|
||||||
icon: Icons.skip_next_rounded,
|
icon: Icons.skip_next_rounded,
|
||||||
color: textColor.withOpacity(expandedElementsOpacity),
|
color: textColor.withOpacity(expandedElementsOpacity),
|
||||||
@@ -2441,12 +2717,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
child: IconButton(
|
child: AnimatedIconButton(
|
||||||
icon: Icon(icon),
|
icon: icon,
|
||||||
color: color,
|
color: color,
|
||||||
iconSize: 22,
|
iconSize: 22,
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
|||||||
static bool get isPlayerExpanded =>
|
static bool get isPlayerExpanded =>
|
||||||
globalPlayerKey.currentState?.isExpanded ?? false;
|
globalPlayerKey.currentState?.isExpanded ?? false;
|
||||||
|
|
||||||
|
/// Check if the queue panel is currently open
|
||||||
|
static bool get isQueuePanelOpen =>
|
||||||
|
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
|
||||||
|
|
||||||
|
/// Close the queue panel if open
|
||||||
|
static void closeQueuePanel() {
|
||||||
|
globalPlayerKey.currentState?.closeQueuePanel();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current expansion progress (0.0 to 1.0)
|
/// Get the current expansion progress (0.0 to 1.0)
|
||||||
static double get expansionProgress =>
|
static double get expansionProgress =>
|
||||||
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
|
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
|
||||||
@@ -483,8 +492,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||||
|
|
||||||
// Base background: use adaptive surface color if available, otherwise default surface
|
// Base background: use adaptive surface color if available, otherwise default surface
|
||||||
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
// Use brightness-aware method to respect light/dark mode
|
||||||
? themeProvider.adaptiveSurfaceColor!
|
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
||||||
|
final baseBgColor = (themeProvider.adaptiveTheme && adaptiveBg != null)
|
||||||
|
? adaptiveBg
|
||||||
: colorScheme.surface;
|
: colorScheme.surface;
|
||||||
|
|
||||||
return ValueListenableBuilder<PlayerExpansionState>(
|
return ValueListenableBuilder<PlayerExpansionState>(
|
||||||
@@ -643,7 +654,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
left: 24,
|
left: 24,
|
||||||
right: 24,
|
right: 24,
|
||||||
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
|
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
|
||||||
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
|
// Use viewPadding to match BottomNavigationBar's height calculation
|
||||||
|
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).viewPadding.bottom + 32,
|
||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
opacity: CurvedAnimation(
|
opacity: CurvedAnimation(
|
||||||
parent: _welcomeFadeController,
|
parent: _welcomeFadeController,
|
||||||
@@ -684,7 +696,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
PlayerRevealOverlay(
|
PlayerRevealOverlay(
|
||||||
key: _revealKey,
|
key: _revealKey,
|
||||||
onDismiss: _hidePlayerReveal,
|
onDismiss: _hidePlayerReveal,
|
||||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
// Use viewPadding to match BottomNavigationBar's height calculation
|
||||||
|
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
|
||||||
miniPlayerHeight: 64,
|
miniPlayerHeight: 64,
|
||||||
showOnboardingHints: _isOnboardingReveal,
|
showOnboardingHints: _isOnboardingReveal,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../../providers/music_assistant_provider.dart';
|
import '../../providers/music_assistant_provider.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../services/debug_logger.dart';
|
||||||
|
import '../../services/settings_service.dart';
|
||||||
|
|
||||||
|
final _volumeLogger = DebugLogger();
|
||||||
|
|
||||||
/// A player card that matches the mini player's visual style.
|
/// A player card that matches the mini player's visual style.
|
||||||
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
||||||
@@ -23,10 +28,16 @@ class PlayerCard extends StatefulWidget {
|
|||||||
final VoidCallback? onSkipNext;
|
final VoidCallback? onSkipNext;
|
||||||
final VoidCallback? onPower;
|
final VoidCallback? onPower;
|
||||||
final ValueChanged<double>? onVolumeChange;
|
final ValueChanged<double>? onVolumeChange;
|
||||||
|
final ValueChanged<bool>? onPrecisionModeChanged;
|
||||||
|
|
||||||
// Pastel yellow for grouped players
|
// Pastel yellow for grouped players
|
||||||
static const Color groupBorderColor = Color(0xFFFFF59D);
|
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||||
|
|
||||||
|
// Precision mode settings
|
||||||
|
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
|
||||||
|
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
|
||||||
|
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
|
||||||
|
|
||||||
const PlayerCard({
|
const PlayerCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
@@ -43,6 +54,7 @@ class PlayerCard extends StatefulWidget {
|
|||||||
this.onSkipNext,
|
this.onSkipNext,
|
||||||
this.onPower,
|
this.onPower,
|
||||||
this.onVolumeChange,
|
this.onVolumeChange,
|
||||||
|
this.onPrecisionModeChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,7 +66,69 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
double _dragVolumeLevel = 0.0;
|
double _dragVolumeLevel = 0.0;
|
||||||
double _cardWidth = 0.0;
|
double _cardWidth = 0.0;
|
||||||
int _lastVolumeUpdateTime = 0;
|
int _lastVolumeUpdateTime = 0;
|
||||||
|
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
||||||
|
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
||||||
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||||
|
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
|
||||||
|
|
||||||
|
// Precision mode state
|
||||||
|
bool _inPrecisionMode = false;
|
||||||
|
Timer? _precisionTimer;
|
||||||
|
Offset? _lastDragPosition;
|
||||||
|
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
|
||||||
|
bool _precisionModeEnabled = true; // From settings
|
||||||
|
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
|
||||||
|
double _precisionStartX = 0.0; // Finger X position when precision mode started
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPrecisionModeSetting();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPrecisionModeSetting() async {
|
||||||
|
final enabled = await SettingsService.getVolumePrecisionMode();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_precisionModeEnabled = enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_precisionTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enterPrecisionMode() {
|
||||||
|
if (_inPrecisionMode) return;
|
||||||
|
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
|
||||||
|
setState(() {
|
||||||
|
_inPrecisionMode = true;
|
||||||
|
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
|
||||||
|
_precisionStartX = _lastLocalX; // Capture finger position at entry
|
||||||
|
});
|
||||||
|
widget.onPrecisionModeChanged?.call(true);
|
||||||
|
_volumeLogger.debug(
|
||||||
|
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
|
||||||
|
context: 'Volume',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitPrecisionMode() {
|
||||||
|
_precisionTimer?.cancel();
|
||||||
|
_precisionTimer = null;
|
||||||
|
if (!_inPrecisionMode) return;
|
||||||
|
setState(() {
|
||||||
|
_inPrecisionMode = false;
|
||||||
|
});
|
||||||
|
widget.onPrecisionModeChanged?.call(false);
|
||||||
|
_volumeLogger.debug(
|
||||||
|
'PRECISION_MODE [${widget.player.name}]: EXITED',
|
||||||
|
context: 'Volume',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -67,7 +141,10 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
final filledColor = widget.backgroundColor;
|
final filledColor = widget.backgroundColor;
|
||||||
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
|
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
|
||||||
|
|
||||||
return LayoutBuilder(
|
// RepaintBoundary isolates this card's repaint from parent transform animations
|
||||||
|
// This improves performance during the staggered reveal animation
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_cardWidth = constraints.maxWidth;
|
_cardWidth = constraints.maxWidth;
|
||||||
|
|
||||||
@@ -133,6 +210,7 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,25 +360,83 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragStart(DragStartDetails details) {
|
void _onDragStart(DragStartDetails details) {
|
||||||
// Get current volume from player
|
// For consecutive swipes, use local volume (API may not have updated player state yet)
|
||||||
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final timeSinceLastDrag = now - _lastDragEndTime;
|
||||||
|
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
|
||||||
|
|
||||||
|
// Use local volume if:
|
||||||
|
// 1. We have a local override (we've swiped before) AND
|
||||||
|
// 2. We're within the consecutive swipe window (5 seconds)
|
||||||
|
// Otherwise, read fresh from player state
|
||||||
|
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
|
||||||
|
|
||||||
|
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
||||||
|
final startVolume = useLocalVolume
|
||||||
|
? _dragVolumeLevel // Continue from where last swipe ended
|
||||||
|
: playerVolume; // Fresh from player
|
||||||
|
|
||||||
|
_volumeLogger.debug(
|
||||||
|
'DRAG_START [${widget.player.name}]: '
|
||||||
|
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
|
||||||
|
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
|
||||||
|
'localVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||||
|
'playerVol=${(playerVolume * 100).round()}%, '
|
||||||
|
'startVol=${(startVolume * 100).round()}%',
|
||||||
|
context: 'Volume',
|
||||||
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingVolume = true;
|
_isDraggingVolume = true;
|
||||||
_dragVolumeLevel = currentVolume;
|
_dragVolumeLevel = startVolume;
|
||||||
});
|
});
|
||||||
|
_lastDragPosition = details.globalPosition;
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragUpdate(DragUpdateDetails details) {
|
void _onDragUpdate(DragUpdateDetails details) {
|
||||||
if (!_isDraggingVolume || _cardWidth <= 0) return;
|
if (!_isDraggingVolume || _cardWidth <= 0) return;
|
||||||
|
|
||||||
// Directional volume: drag right = increase, drag left = decrease
|
// Track local X position for precision mode
|
||||||
// Distance determines amount of change (full card width = 100%)
|
_lastLocalX = details.localPosition.dx;
|
||||||
// Multiple swipes accumulate - each starts from current volume
|
|
||||||
final dragDelta = details.delta.dx;
|
|
||||||
final volumeDelta = dragDelta / _cardWidth;
|
|
||||||
|
|
||||||
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
// Check for stillness to trigger precision mode (only if enabled in settings)
|
||||||
|
final currentPosition = details.globalPosition;
|
||||||
|
if (_precisionModeEnabled && _lastDragPosition != null) {
|
||||||
|
final movement = (currentPosition - _lastDragPosition!).distance;
|
||||||
|
|
||||||
|
if (movement < PlayerCard.precisionStillnessThreshold) {
|
||||||
|
// Finger is still - start precision timer if not already running
|
||||||
|
if (_precisionTimer == null && !_inPrecisionMode) {
|
||||||
|
_precisionTimer = Timer(
|
||||||
|
Duration(milliseconds: PlayerCard.precisionTriggerMs),
|
||||||
|
_enterPrecisionMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Finger moved - cancel timer (but don't exit precision mode if already in it)
|
||||||
|
_precisionTimer?.cancel();
|
||||||
|
_precisionTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastDragPosition = currentPosition;
|
||||||
|
|
||||||
|
double newVolume;
|
||||||
|
|
||||||
|
if (_inPrecisionMode) {
|
||||||
|
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||||
|
// Full card width of movement = precisionSensitivity (10%) change
|
||||||
|
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
|
||||||
|
final offsetX = details.localPosition.dx - _precisionStartX;
|
||||||
|
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
|
||||||
|
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
|
||||||
|
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||||
|
} else {
|
||||||
|
// NORMAL MODE: Delta-based movement (full card width = 100%)
|
||||||
|
final dragDelta = details.delta.dx;
|
||||||
|
final volumeDelta = dragDelta / _cardWidth;
|
||||||
|
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
// Always update visual
|
// Always update visual
|
||||||
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
||||||
@@ -319,7 +455,17 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
|
|
||||||
void _onDragEnd(DragEndDetails details) {
|
void _onDragEnd(DragEndDetails details) {
|
||||||
// Send final volume on release
|
// Send final volume on release
|
||||||
|
_volumeLogger.debug(
|
||||||
|
'DRAG_END [${widget.player.name}]: '
|
||||||
|
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||||
|
'precisionMode=$_inPrecisionMode',
|
||||||
|
context: 'Volume',
|
||||||
|
);
|
||||||
widget.onVolumeChange?.call(_dragVolumeLevel);
|
widget.onVolumeChange?.call(_dragVolumeLevel);
|
||||||
|
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
|
||||||
|
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
|
||||||
|
_exitPrecisionMode();
|
||||||
|
_lastDragPosition = null;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingVolume = false;
|
_isDraggingVolume = false;
|
||||||
});
|
});
|
||||||
@@ -327,6 +473,8 @@ class _PlayerCardState extends State<PlayerCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragCancel() {
|
void _onDragCancel() {
|
||||||
|
_exitPrecisionMode();
|
||||||
|
_lastDragPosition = null;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingVolume = false;
|
_isDraggingVolume = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
// Use the elastic animation value directly (already has bounce)
|
// Use the elastic animation value directly (already has bounce)
|
||||||
final slideOffset = distanceToTravel * (1.0 - t);
|
final slideOffset = distanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
|
// Darken cards when:
|
||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(0, slideOffset),
|
offset: Offset(0, slideOffset),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
+300
-121
@@ -1,12 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:great_list_view/great_list_view.dart';
|
||||||
import '../../providers/music_assistant_provider.dart';
|
import '../../providers/music_assistant_provider.dart';
|
||||||
import '../../models/player.dart';
|
import '../../models/player.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../common/empty_state.dart';
|
import '../common/empty_state.dart';
|
||||||
|
|
||||||
/// Panel that displays the current playback queue
|
/// Panel that displays the current playback queue with drag-to-reorder
|
||||||
class QueuePanel extends StatelessWidget {
|
/// and swipe-left-to-delete functionality
|
||||||
|
class QueuePanel extends StatefulWidget {
|
||||||
final MusicAssistantProvider maProvider;
|
final MusicAssistantProvider maProvider;
|
||||||
final PlayerQueue? queue;
|
final PlayerQueue? queue;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
@@ -31,49 +33,145 @@ class QueuePanel extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<QueuePanel> createState() => _QueuePanelState();
|
||||||
return Container(
|
}
|
||||||
color: backgroundColor,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
|
|
||||||
onPressed: onClose,
|
|
||||||
padding: Spacing.paddingAll12,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
'Queue',
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
|
|
||||||
onPressed: onRefresh,
|
|
||||||
padding: Spacing.paddingAll12,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Queue content
|
class _QueuePanelState extends State<QueuePanel> {
|
||||||
Expanded(
|
final AnimatedListController _listController = AnimatedListController();
|
||||||
child: isLoading
|
List<QueueItem> _items = [];
|
||||||
? Center(child: CircularProgressIndicator(color: primaryColor))
|
|
||||||
: queue == null || queue!.items.isEmpty
|
// Track pointer for right-swipe-to-close (bypasses gesture arena)
|
||||||
? _buildEmptyState(context)
|
Offset? _pointerStart;
|
||||||
: _buildQueueList(),
|
static const _swipeThreshold = 80.0; // Minimum horizontal distance for swipe
|
||||||
),
|
|
||||||
],
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_items = widget.queue?.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(QueuePanel oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
final newItems = widget.queue?.items ?? [];
|
||||||
|
|
||||||
|
// Sync items when:
|
||||||
|
// 1. Player changed (different queue entirely)
|
||||||
|
// 2. Item count changed (deletion confirmed by server, or track added)
|
||||||
|
// 3. Item order changed (shuffle toggle, manual reorder)
|
||||||
|
final playerChanged = widget.queue?.playerId != oldWidget.queue?.playerId;
|
||||||
|
final countChanged = newItems.length != _items.length;
|
||||||
|
final orderChanged = !_itemListsEqual(newItems, _items);
|
||||||
|
|
||||||
|
if (playerChanged || countChanged || orderChanged) {
|
||||||
|
_items = newItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two item lists by queueItemId to detect reordering
|
||||||
|
bool _itemListsEqual(List<QueueItem> a, List<QueueItem> b) {
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (int i = 0; i < a.length; i++) {
|
||||||
|
if (a[i].queueItemId != b[i].queueItemId) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration? duration) {
|
||||||
|
if (duration == null) return '';
|
||||||
|
final totalSeconds = duration.inSeconds;
|
||||||
|
final minutes = totalSeconds ~/ 60;
|
||||||
|
final seconds = totalSeconds % 60;
|
||||||
|
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDelete(QueueItem item) async {
|
||||||
|
// DON'T update _items - let Dismissible handle the visual removal
|
||||||
|
// Updating _items causes AutomaticAnimatedListView to also animate,
|
||||||
|
// creating a double-animation bug
|
||||||
|
|
||||||
|
// Just call the API - fire and forget
|
||||||
|
final playerId = widget.queue?.playerId;
|
||||||
|
if (playerId != null) {
|
||||||
|
widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleReorder(int oldIndex, int newIndex) async {
|
||||||
|
// Reorder via API - use playerId as queue ID
|
||||||
|
final playerId = widget.queue?.playerId;
|
||||||
|
if (playerId != null && oldIndex != newIndex) {
|
||||||
|
final item = _items[oldIndex];
|
||||||
|
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, newIndex);
|
||||||
|
widget.onRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Use Listener for raw pointer events to detect right-swipe-to-close
|
||||||
|
// This bypasses the gesture arena so Dismissible doesn't block it
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: (event) {
|
||||||
|
_pointerStart = event.position;
|
||||||
|
},
|
||||||
|
onPointerMove: (event) {
|
||||||
|
if (_pointerStart != null) {
|
||||||
|
final dx = event.position.dx - _pointerStart!.dx;
|
||||||
|
final dy = (event.position.dy - _pointerStart!.dy).abs();
|
||||||
|
// Right swipe: horizontal movement > threshold, mostly horizontal (not vertical)
|
||||||
|
if (dx > _swipeThreshold && dx > dy * 2) {
|
||||||
|
_pointerStart = null; // Reset to prevent multiple triggers
|
||||||
|
widget.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointerUp: (_) => _pointerStart = null,
|
||||||
|
onPointerCancel: (_) => _pointerStart = null,
|
||||||
|
child: Container(
|
||||||
|
color: widget.backgroundColor,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
padding: Spacing.paddingAll12,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'Queue',
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.refresh_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||||
|
onPressed: widget.onRefresh,
|
||||||
|
padding: Spacing.paddingAll12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Queue content
|
||||||
|
Expanded(
|
||||||
|
child: widget.isLoading
|
||||||
|
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
|
||||||
|
: widget.queue == null || _items.isEmpty
|
||||||
|
? _buildEmptyState(context)
|
||||||
|
: _buildQueueList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,96 +181,177 @@ class QueuePanel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQueueList() {
|
Widget _buildQueueList() {
|
||||||
final currentIndex = queue!.currentIndex ?? 0;
|
final currentIndex = widget.queue!.currentIndex ?? 0;
|
||||||
final items = queue!.items;
|
|
||||||
|
|
||||||
return ListView.builder(
|
return AutomaticAnimatedListView<QueueItem>(
|
||||||
|
list: _items,
|
||||||
padding: Spacing.paddingH8,
|
padding: Spacing.paddingH8,
|
||||||
cacheExtent: 500,
|
comparator: AnimatedListDiffListComparator<QueueItem>(
|
||||||
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
|
sameItem: (a, b) => a.queueItemId == b.queueItemId,
|
||||||
addRepaintBoundaries: false, // We add RepaintBoundary manually
|
sameContent: (a, b) => a.track.name == b.track.name && a.track.duration == b.track.duration,
|
||||||
itemCount: items.length,
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, item, data) {
|
||||||
final item = items[index];
|
final index = _items.indexOf(item);
|
||||||
final isCurrentItem = index == currentIndex;
|
final isCurrentItem = index == currentIndex;
|
||||||
final isPastItem = index < currentIndex;
|
final isPastItem = index < currentIndex;
|
||||||
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
|
|
||||||
|
|
||||||
return RepaintBoundary(
|
// Always return same widget structure (with Dismissible) for consistency
|
||||||
child: Opacity(
|
// Only vary the measuring flag for image loading optimization
|
||||||
opacity: isPastItem ? 0.5 : 1.0,
|
return _buildDismissibleQueueItem(item, index, isCurrentItem, isPastItem, measuring: data.measuring);
|
||||||
child: Container(
|
},
|
||||||
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
|
listController: _listController,
|
||||||
decoration: BoxDecoration(
|
reorderModel: AnimatedListReorderModel(
|
||||||
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
|
onReorderStart: (index, dx, dy) => true,
|
||||||
borderRadius: BorderRadius.circular(Radii.md),
|
onReorderFeedback: (index, dropIndex, offset, dx, dy) => null,
|
||||||
),
|
onReorderMove: (index, dropIndex) => true,
|
||||||
child: ListTile(
|
onReorderComplete: (index, dropIndex, slot) {
|
||||||
dense: true,
|
_handleReorder(index, dropIndex);
|
||||||
leading: ClipRRect(
|
return true;
|
||||||
borderRadius: BorderRadius.circular(Radii.sm),
|
},
|
||||||
child: SizedBox(
|
),
|
||||||
width: 44,
|
addLongPressReorderable: false, // We use custom drag handle instead
|
||||||
height: 44,
|
);
|
||||||
child: imageUrl != null
|
}
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
Widget _buildDismissibleQueueItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {bool measuring = false}) {
|
||||||
fit: BoxFit.cover,
|
return Dismissible(
|
||||||
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
|
key: ValueKey(item.queueItemId),
|
||||||
memCacheWidth: 176,
|
direction: DismissDirection.endToStart, // Swipe left to delete only
|
||||||
memCacheHeight: 176,
|
background: Container(
|
||||||
fadeInDuration: Duration.zero,
|
alignment: Alignment.centerRight,
|
||||||
fadeOutDuration: Duration.zero,
|
padding: const EdgeInsets.only(right: 20),
|
||||||
placeholder: (context, url) => Container(
|
decoration: BoxDecoration(
|
||||||
color: textColor.withOpacity(0.1),
|
color: Colors.red.shade700,
|
||||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
|
||||||
color: textColor.withOpacity(0.1),
|
),
|
||||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
confirmDismiss: (direction) async {
|
||||||
),
|
// Don't allow deleting currently playing track
|
||||||
)
|
return !isCurrentItem;
|
||||||
: Container(
|
},
|
||||||
color: textColor.withOpacity(0.1),
|
onDismissed: (direction) {
|
||||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
_handleDelete(item);
|
||||||
|
},
|
||||||
|
child: _buildQueueItem(item, index, isCurrentItem, isPastItem, measuring: measuring),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQueueItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {bool measuring = false}) {
|
||||||
|
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
||||||
|
final duration = _formatDuration(item.track.duration);
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: isPastItem ? 0.5 : 1.0,
|
||||||
|
child: Container(
|
||||||
|
// Use background color with zero radius to cover any grey from list package
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrentItem ? widget.primaryColor.withOpacity(0.15) : widget.backgroundColor,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(Radii.sm),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: imageUrl != null && !measuring
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 176,
|
||||||
|
memCacheHeight: 176,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
color: widget.textColor.withOpacity(0.1),
|
||||||
|
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||||
),
|
),
|
||||||
),
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: widget.textColor.withOpacity(0.1),
|
||||||
|
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: widget.textColor.withOpacity(0.1),
|
||||||
|
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
),
|
||||||
item.track.name,
|
title: Text(
|
||||||
style: TextStyle(
|
item.track.name,
|
||||||
color: isCurrentItem ? primaryColor : textColor,
|
style: TextStyle(
|
||||||
fontSize: 14,
|
color: isCurrentItem ? widget.primaryColor : widget.textColor,
|
||||||
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
fontSize: 14,
|
||||||
),
|
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
maxLines: 1,
|
||||||
? Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
item.track.artists!.first.name,
|
),
|
||||||
|
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
item.track.artists!.first.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (duration.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Text(
|
||||||
|
duration,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(0.6),
|
color: widget.textColor.withOpacity(0.5),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
)
|
if (isCurrentItem)
|
||||||
: null,
|
Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20)
|
||||||
trailing: isCurrentItem
|
else if (!measuring)
|
||||||
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
|
Builder(
|
||||||
: null,
|
builder: (itemContext) => GestureDetector(
|
||||||
onTap: () {
|
onVerticalDragStart: (details) {
|
||||||
// TODO: Jump to this track in queue
|
_listController.notifyStartReorder(
|
||||||
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint
|
itemContext,
|
||||||
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it
|
details.localPosition.dx,
|
||||||
// Alternative: Could use multiple next() calls but that's inefficient and unreliable
|
details.localPosition.dy,
|
||||||
},
|
);
|
||||||
|
},
|
||||||
|
onVerticalDragUpdate: (details) {
|
||||||
|
_listController.notifyUpdateReorder(
|
||||||
|
details.localPosition.dx,
|
||||||
|
details.localPosition.dy,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onVerticalDragEnd: (details) {
|
||||||
|
_listController.notifyStopReorder(false);
|
||||||
|
},
|
||||||
|
onVerticalDragCancel: () {
|
||||||
|
_listController.notifyStopReorder(true);
|
||||||
|
},
|
||||||
|
child: Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Jump to this track in queue if MA API supports it
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../screens/playlist_details_screen.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
|
||||||
|
class PlaylistCard extends StatelessWidget {
|
||||||
|
final Playlist playlist;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final int? imageCacheSize;
|
||||||
|
|
||||||
|
const PlaylistCard({
|
||||||
|
super.key,
|
||||||
|
required this.playlist,
|
||||||
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.imageCacheSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||||
|
final cacheSize = imageCacheSize ?? 256;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap ?? () {
|
||||||
|
// Update adaptive colors immediately on tap
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: PlaylistDetailsScreen(
|
||||||
|
playlist: playlist,
|
||||||
|
provider: playlist.provider,
|
||||||
|
itemId: playlist.itemId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Playlist artwork
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Hero(
|
||||||
|
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: cacheSize,
|
||||||
|
memCacheHeight: cacheSize,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) => Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.playlist_play_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.playlist_play_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Playlist title
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
playlist.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Owner/track count
|
||||||
|
Text(
|
||||||
|
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import 'playlist_card.dart';
|
||||||
|
|
||||||
|
class PlaylistRow extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final Future<List<Playlist>> Function() loadPlaylists;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
final List<Playlist>? Function()? getCachedPlaylists;
|
||||||
|
|
||||||
|
const PlaylistRow({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadPlaylists,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
|
this.getCachedPlaylists,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistRow> createState() => _PlaylistRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClientMixin {
|
||||||
|
List<Playlist> _playlists = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPlaylists();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPlaylists() async {
|
||||||
|
if (_hasLoaded) return;
|
||||||
|
_hasLoaded = true;
|
||||||
|
|
||||||
|
// 1. Try to get cached data synchronously for instant display
|
||||||
|
final cachedPlaylists = widget.getCachedPlaylists?.call();
|
||||||
|
if (cachedPlaylists != null && cachedPlaylists.isNotEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_playlists = cachedPlaylists;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load fresh data
|
||||||
|
try {
|
||||||
|
final freshPlaylists = await widget.loadPlaylists();
|
||||||
|
if (mounted && freshPlaylists.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_playlists = freshPlaylists;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precachePlaylistImages(freshPlaylists);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _precachePlaylistImages(List<Playlist> playlists) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
final playlistsToCache = playlists.take(10);
|
||||||
|
|
||||||
|
for (final playlist in playlistsToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) => false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
|
||||||
|
// Don't render if empty and done loading
|
||||||
|
if (_playlists.isEmpty && !_isLoading) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Calculate card dimensions
|
||||||
|
// Default: 44 for title, remaining for content (artwork + 2 lines of text)
|
||||||
|
final rowHeight = widget.rowHeight ?? 237.0;
|
||||||
|
final titleHeight = 44.0;
|
||||||
|
final contentHeight = rowHeight - titleHeight;
|
||||||
|
// Artwork is square, text takes ~50px (8 padding + title + owner)
|
||||||
|
final artworkSize = contentHeight - 50;
|
||||||
|
final cardWidth = artworkSize;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Section title
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Horizontal list of playlists
|
||||||
|
SizedBox(
|
||||||
|
height: contentHeight,
|
||||||
|
child: _isLoading && _playlists.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _playlists.length,
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = _playlists[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: cardWidth,
|
||||||
|
child: PlaylistCard(
|
||||||
|
playlist: playlist,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
imageCacheSize: 256,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
|
||||||
|
class RadioStationCard extends StatelessWidget {
|
||||||
|
final MediaItem radioStation;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final int? imageCacheSize;
|
||||||
|
|
||||||
|
const RadioStationCard({
|
||||||
|
super.key,
|
||||||
|
required this.radioStation,
|
||||||
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.imageCacheSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||||
|
final cacheSize = imageCacheSize ?? 256;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap ?? () {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
// Play the radio station on selected player
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
if (selectedPlayer != null) {
|
||||||
|
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Radio station artwork - circular for radio
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Hero(
|
||||||
|
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||||
|
child: ClipOval(
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: cacheSize,
|
||||||
|
memCacheHeight: cacheSize,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) => Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.radio_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.radio_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Radio station name - centered for circular images
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
radioStation.name,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import 'radio_station_card.dart';
|
||||||
|
|
||||||
|
class RadioStationRow extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final Future<List<MediaItem>> Function() loadRadioStations;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
final List<MediaItem>? Function()? getCachedRadioStations;
|
||||||
|
|
||||||
|
const RadioStationRow({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadRadioStations,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
|
this.getCachedRadioStations,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RadioStationRow> createState() => _RadioStationRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAliveClientMixin {
|
||||||
|
List<MediaItem> _radioStations = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadRadioStations();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRadioStations() async {
|
||||||
|
if (_hasLoaded) return;
|
||||||
|
_hasLoaded = true;
|
||||||
|
|
||||||
|
// 1. Try to get cached data synchronously for instant display
|
||||||
|
final cachedStations = widget.getCachedRadioStations?.call();
|
||||||
|
if (cachedStations != null && cachedStations.isNotEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_radioStations = cachedStations;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load fresh data
|
||||||
|
try {
|
||||||
|
final freshStations = await widget.loadRadioStations();
|
||||||
|
if (mounted && freshStations.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_radioStations = freshStations;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precacheRadioImages(freshStations);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _precacheRadioImages(List<MediaItem> stations) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
final stationsToCache = stations.take(10);
|
||||||
|
|
||||||
|
for (final station in stationsToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(station, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) => false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
|
||||||
|
// Don't render if empty and done loading
|
||||||
|
if (_radioStations.isEmpty && !_isLoading) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Calculate card dimensions - similar to ArtistRow for circular items
|
||||||
|
// Default: 44 for title, remaining for content (circular image + name)
|
||||||
|
final rowHeight = widget.rowHeight ?? 207.0;
|
||||||
|
final titleHeight = 44.0;
|
||||||
|
final contentHeight = rowHeight - titleHeight;
|
||||||
|
// Circular artwork, text takes ~40px (8 padding + name lines)
|
||||||
|
final artworkSize = contentHeight - 40;
|
||||||
|
final cardWidth = artworkSize;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Section title
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Horizontal list of radio stations
|
||||||
|
SizedBox(
|
||||||
|
height: contentHeight,
|
||||||
|
child: _isLoading && _radioStations.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _radioStations.length,
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final station = _radioStations[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: cardWidth,
|
||||||
|
child: RadioStationCard(
|
||||||
|
radioStation: station,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
imageCacheSize: 256,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.8.0-beta+37
|
version: 2.8.5-beta+43
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -49,6 +49,9 @@ dependencies:
|
|||||||
# Raw PCM Audio Playback (for Sendspin streaming)
|
# Raw PCM Audio Playback (for Sendspin streaming)
|
||||||
flutter_pcm_sound: ^3.3.3
|
flutter_pcm_sound: ^3.3.3
|
||||||
|
|
||||||
|
# Animated list with drag-to-reorder
|
||||||
|
great_list_view: ^0.2.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Reference in New Issue
Block a user