Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 90f004ea47 chore: Bump version to 2.8.5-beta+43
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:00:33 +00:00
Your NameandClaude Opus 4.5 f24c98102c refactor: Replace visual feedback with haptic vibration for precision mode
Remove darkening and desaturation visual effects from precision volume control,
replacing with simple haptic feedback when entering precision mode. Cleaner UX
that doesn't obscure the UI while still providing clear tactile confirmation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:44:42 +00:00
Your Name 31a9f24d66 fix: Increase precision mode visual feedback to 50% darkness and desaturation 2026-01-07 09:29:59 +00:00
Your Name 29f2ff707e fix: Use correct API method to play radio stations 2026-01-07 08:47:21 +00:00
Your NameandClaude Opus 4.5 318a095c15 feat: Add optional Favorite Playlists and Favorite Radio Stations rows
- Added PlaylistCard and PlaylistRow widgets with hero animations
- Added RadioStationCard and RadioStationRow widgets with hero animations
- Added hero tags for playlists and radio stations
- Added settings to show/hide the new rows on home screen
- Added provider methods getFavoritePlaylists() and getFavoriteRadioStations()
- Added localization strings for the new rows
- Radio stations use circular images like artists
- Playlists use square images like albums

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 08:39:58 +00:00
Your Name 1796829a77 fix: Reduce darkness to 30% and desaturation to 25% for subtler effect 2026-01-07 08:31:28 +00:00
Your Name 96e6522a72 fix: Reduce desaturation to 50% for subtler effect 2026-01-07 08:22:46 +00:00
Your NameandClaude Opus 4.5 cde13864a3 feat: Darken and desaturate UI when in precision mode
- Mini player now darkens/desaturates when player list card is in precision mode
- Player list cards darken/desaturate when another card or mini player is in precision mode
- Content behind mini player darkens when in precision mode (during reveal)
- Uses animated saturation matrix for smooth grayscale transition

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 08:13:48 +00:00
Your NameandClaude Opus 4.5 ea5beec889 fix: Use offset-based movement for precision mode
Previous implementation mapped absolute finger position to zoomed range,
causing a jump when entering precision mode. Now uses offset from entry
point:

- Records finger X position when entering precision mode
- Movement FROM that position maps to the zoomed range
- Full card width of movement = 10% volume change
- No jump when entering precision mode

Example at 40%:
- Hold still → enter precision mode, finger at position X
- Move full card width RIGHT → volume goes to 50%
- Move full card width LEFT → volume goes to 30%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:51:13 +00:00
Your NameandClaude Opus 4.5 b75152d4ad fix: Improve volume precision mode with zoom range and dark overlay
Issues fixed:
1. Precision mode now uses zoomed range mapping instead of sensitivity
   - At 40% volume, left edge = 35%, right edge = 45% (10% range)
   - Full card width control maintained, just zoomed into smaller range

2. Visual feedback now darkens instead of reducing opacity
   - Black overlay (60% opacity) instead of reducing card opacity to 30%
   - Proper darkening effect as requested

3. Mini player precision mode now darkens all player cards
   - Added callback from ExpandablePlayer to GlobalPlayerOverlay
   - GlobalPlayerOverlay passes state to PlayerRevealOverlay
   - All cards darken when mini player enters precision mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:41:08 +00:00
Your NameandClaude Opus 4.5 970644493e feat: Add volume precision mode for fine-grained control
Hold still while adjusting volume to enter precision mode (10x more
precise). This helps with low-volume fine-tuning like audiobooks at 3%.

Features:
- Hold still for 800ms during volume drag to enter precision mode
- 10x sensitivity reduction (full swipe = 10% change instead of 100%)
- Other player cards dim when one is in precision mode (spotlight effect)
- New setting to enable/disable precision mode (Settings > Players)
- Works on both player cards and mini player volume swipe

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:20:06 +00:00
Your NameandClaude Opus 4.5 80996e3e82 fix: light theme improvements
- Status bar icons now use dark icons in light theme (main.dart)
- Expanded player text/primary color fallbacks use colorScheme instead of white
- Placeholder art uses theme-aware colors
- Settings logo uses dark color filter in light theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:18:28 +00:00
Your NameandClaude Opus 4.5 9584f806ee fix: nav bar uses scheme.surface to match expanded player
Expanded player uses adaptiveScheme.surface for its background.
Nav bar was incorrectly using primaryContainer or HSL manipulation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:07:15 +00:00
Your NameandClaude Opus 4.5 e2f420df0c fix: nav bar uses subtle tint instead of primaryContainer
- Dark mode: lightness 0.10, saturation 0.12 (very dark)
- Light mode: lightness 0.98, saturation 0.04 (nearly white)

Nav bar should be more neutral than player card.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:01:59 +00:00
Your NameandClaude Opus 4.5 11faba74ea debug: add volume swipe logging for diagnosis
Logs drag start/end with key state values:
- useLocalVolume decision
- hasOverride flag
- time since last drag
- local vs player volume

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:56:00 +00:00
Your NameandClaude Opus 4.5 6459f8bf87 fix: nav bar uses same adaptive color as expanded player
Use primaryContainer from appropriate light/dark scheme instead
of custom HSL manipulation. This matches how the expanded player
gets its background color, ensuring consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:53:52 +00:00
Your NameandClaude Opus 4.5 cd1ab6e9f7 Add player animation audit prompt
Comprehensive audit prompt for polishing the player system animations
including mini player, expanded player, queue panel, and player selector.
Uses ultrathink and sub-agents for thorough analysis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:17:43 +00:00
Your NameandClaude Opus 4.5 8e5bfa3657 Add defensive queue check in collapse() method
Belt-and-suspenders fix: collapse() now checks if queue panel is open
and closes it instead of collapsing the player. This ensures queue
always closes first regardless of how collapse() is called.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:06:50 +00:00
Your NameandClaude Opus 4.5 01ea21917a Fix Android back button to close queue panel before collapsing player
The back button handling in home_screen.dart was checking for expanded
player but not for queue panel. Since home_screen's PopScope intercepts
all back presses (canPop: false), the expandable_player's PopScope was
never reached.

Added:
- closeQueuePanel() method to ExpandablePlayerState
- isQueuePanelOpen and closeQueuePanel() static accessors to GlobalPlayerOverlay
- Queue panel check in home_screen.dart back button handler (before player check)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:58:35 +00:00
Your NameandClaude Opus 4.5 236698f1b4 Fix queue panel navigation and back button handling
- Use raw Listener instead of GestureDetector for right-swipe-to-close
  detection on the entire queue panel. This bypasses the gesture arena
  so Dismissible widgets don't block the gesture.
- Update PopScope to handle both queue panel and expanded player:
  back button now closes queue first, then collapses player, then exits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:48:17 +00:00
Your NameandClaude Opus 4.5 0699523914 Add Android back + swipe right navigation for queue
- PopScope in expandable_player: back button closes queue first
- GestureDetector in queue_panel: swipe right closes queue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:30:31 +00:00
Your NameandClaude Opus 4.5 9ebd49d7b3 Don't update _items on delete - let Dismissible handle visual
Removing from _items causes AutomaticAnimatedListView to also animate
the removal, conflicting with Dismissible's animation. Let Dismissible
handle the visual removal alone, API updates server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:20:43 +00:00
Your NameandClaude Opus 4.5 ab734ac996 Use consistent widget structure for measuring
Always wrap in Dismissible even during measuring phase to maintain
consistent widget tree structure. Only vary image loading based on
measuring flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:13:58 +00:00
Your NameandClaude Opus 4.5 88fe49d506 Fix didUpdateWidget - only sync on queue/player change
Previously, didUpdateWidget reset _items on every parent rebuild
(reference comparison always true). Now only syncs when switching
to a different player's queue, preserving local state for animations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:00:43 +00:00
Your NameandClaude Opus 4.5 b497a44930 Fix delete animation - don't refresh after API call
Remove onRefresh() after delete to prevent stale data from
bringing back the deleted item (ghost item bug). Local removal
is sufficient - next natural refresh will confirm deletion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:52:48 +00:00
Your NameandClaude Opus 4.5 1324b23167 Fix drag handle - use Builder context like overlay branch
- Use Builder to get itemContext for notifyStartReorder
- Pass localPosition instead of delta for updates
- Remove unused _draggingIndex state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:43:33 +00:00
Your NameandClaude Opus 4.5 c9eb3fb7ec Add instant drag handles (no long press required)
- Use GestureDetector on drag handle icon
- Call controller.notifyStartReorder/Update/Stop on drag events
- Set addLongPressReorderable: false

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:42:29 +00:00
Your NameandClaude Opus 4.5 84bfcf7e06 Fix delete animation bug - remove by ID not index
- Use removeWhere with queueItemId instead of removeAt(index)
- Prevents wrong track being removed due to index mismatch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:41:36 +00:00
Your NameandClaude Opus 4.5 29167dcdf6 Add init.gradle to force Alibaba mirrors + Gradle caching
- Add init.gradle that removes Maven Central and forces Alibaba mirrors
- Add Gradle cache step to workflow
- Copy init.gradle to ~/.gradle before build

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:25:16 +00:00
Your NameandClaude Opus 4.5 01aca2e27c Fix card colors and delete animation
- Set card background to match expanded player background
- Use zero border radius to cover grey from list package
- Remove item from local list immediately on dismiss to prevent
  double animation (Dismissible + AutomaticAnimatedListView)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:18:12 +00:00
Your NameandClaude Opus 4.5 26fcd90557 Add Gradle cache clear step to force mirror usage
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:06:21 +00:00
Your NameandClaude Opus 4.5 129a1c8b28 Fix build errors in queue_panel.dart
- Use playerId instead of queueId (PlayerQueue property)
- Fix _formatDuration to accept Duration? type
- Remove dispose() call (AnimatedListController doesn't have it)
- Remove unsupported reorderableOptions parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:59:19 +00:00
Your NameandClaude Opus 4.5 03f96537eb Add Alibaba Maven mirrors for faster builds
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:53:47 +00:00
Your NameandClaude Opus 4.5 405816939d Add great_list_view queue with drag-reorder and swipe-to-delete
- Convert QueuePanel to StatefulWidget with AutomaticAnimatedListView
- Add long-press drag-to-reorder functionality
- Add swipe-left-to-delete (endToStart only)
- Add queueCommandDeleteItem and queueCommandMoveItem API methods
- Show drag handle icon and track durations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:47:12 +00:00
Your NameandClaude Opus 4.5 6661375f99 chore: bump version to 2.8.4-beta+42
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:18:30 +00:00
Your NameandClaude Opus 4.5 d6e759f1cc fix: remove non-existent Podcast type check
Use mediaType enum check instead of type check since Podcast
is not a distinct class in the model hierarchy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:29:25 +00:00
Your NameandClaude Opus 4.5 8a38516715 feat: overhaul search scoring with fuzzy matching and stopword removal
Implements a modular search scoring system that fixes common search issues:

- Stopword removal: "the Ramones" now finds "Ramones"
- Reverse matching: Query containing result name gets scored
- Fuzzy matching: Jaro-Winkler algorithm handles typos ("beetles" -> "Beatles")
- N-gram matching: Bigrams enable partial string matches

New scoring tiers (highest to lowest):
- Exact match (100)
- Exact after stopword removal (95)
- Starts with (80-85)
- Word boundary (65-70)
- Reverse contains (55-60)
- Contains (45-55)
- Fuzzy high confidence (40-45)
- Fuzzy medium confidence (35)
- N-gram partial (25-35)
- Baseline (20)

New files:
- lib/utils/search/scoring_config.dart - Configurable weights
- lib/utils/search/text_normalizer.dart - Stopword removal
- lib/utils/search/fuzzy_matcher.dart - Jaro-Winkler implementation
- lib/utils/search/ngram_matcher.dart - Bigram matching
- lib/utils/search/search_scoring.dart - Main scorer class

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:18:54 +00:00
Your NameandClaude Opus 4.5 aaeb2109f6 chore: remove debug logging for radio metadata
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:55:58 +00:00
Your NameandClaude Opus 4.5 f65e76e858 fix: prevent radio metadata downgrade from bad events
- Remove faulty existingTrack.name.contains(' - ') check that
  incorrectly marked good metadata as not-proper
- Add protection for malformed titles starting with '- '
- Add debug logging for radio metadata quality comparison

The old check was meant to detect unparsed "Artist - Title" format
but it also blocked legitimate titles like "Jazzy French - La musique
revient vite..." from being recognized as proper metadata.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:31:49 +00:00
Your NameandClaude Opus 4.5 252395d164 Fix _currentTrack not updating for radio streams
The cache was being updated correctly with stream metadata, but
_currentTrack (which the UI displays) was only updated for podcasts.

Now also updates _currentTrack for radio when stream metadata arrives
(detected by artist changing from Unknown to actual value).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:10:24 +00:00
Your NameandClaude Opus 4.5 f36d108878 Fix radio stream metadata not displaying
For radio streams, MA sends now-playing info in `stream_title` field
(e.g., "Jazzy French - La musique revient vite...") while `title` field
contains the station name (e.g., "Jazz Radio Jazzy French").

Previously, stream_title was only used if title was "Unknown Track".
Now we always prefer stream_title for radio streams, parsing the
"Artist - Title" format properly.

Bump version to 2.8.4-beta+41

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:54:14 +00:00
Your NameandClaude Opus 4.5 8e548daf0c Fix black screen bug when Material You theme enabled (#37)
Override Material You ColorScheme backgrounds with app's preferred colors.
Material You generates unpredictable background values that could cause
black screen or inconsistent navigation bar colors.

- Force dark background to 0xFF1a1a1a
- Force dark surface to 0xFF2a2a2a
- Force light background to 0xFFFAFAFA

Bump version to 2.8.3-beta+40

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:27:46 +00:00
Your NameandClaude Opus 4.5 b15f10ade9 Bump version to 2.8.2-beta+39
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:18:41 +00:00
Your NameandClaude Opus 4.5 b9c7bc0e2a Fix Tailscale auth detection (issue #38)
Add .ts.net to _isLocalIpAddress() check so Tailscale URLs get the same
redirect-safe HTTP handling as local IPs. This fixes auth detection
failing to show username/password fields for Tailscale connections.

Fixes #38

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:03:17 +00:00
31 changed files with 2644 additions and 414 deletions
+16 -1
View File
@@ -35,6 +35,16 @@ jobs:
channel: 'stable'
cache: true
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Get dependencies
run: flutter pub get
@@ -65,7 +75,12 @@ jobs:
EOF
- name: Build APK
run: flutter build apk --release
run: |
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
- name: Upload APK artifact
uses: actions/upload-artifact@v4
+182
View File
@@ -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)
+6
View File
@@ -2,6 +2,9 @@ buildscript {
ext.kotlin_version = '1.9.10'
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
@@ -14,6 +17,9 @@ buildscript {
allprojects {
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
}
+37
View File
@@ -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' }
}
}
}
+12
View File
@@ -28,4 +28,16 @@ class HeroTags {
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
/// Playlist title hero tag prefix
static const String playlistTitle = 'playlist_title_';
/// Radio station cover hero tag prefix
static const String radioCover = 'radio_cover_';
/// Radio station title hero tag prefix
static const String radioTitle = 'radio_title_';
}
+12
View File
@@ -251,6 +251,16 @@
"description": "Home screen section title"
},
"favoritePlaylists": "Favorite Playlists",
"@favoritePlaylists": {
"description": "Home screen section title"
},
"favoriteRadioStations": "Favorite Radio Stations",
"@favoriteRadioStations": {
"description": "Home screen section title"
},
"searchMusic": "Search music...",
"@searchMusic": {
"description": "Search placeholder text"
@@ -1062,6 +1072,8 @@
"showFavoriteAlbums": "Show a row of your favorite albums",
"showFavoriteArtists": "Show a row of your favorite artists",
"showFavoriteTracks": "Show a row of your favorite tracks",
"showFavoritePlaylists": "Show a row of your favorite playlists",
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites",
+20 -10
View File
@@ -71,13 +71,16 @@ Future<void> main() async {
DeviceOrientation.portraitDown,
]);
// Set system UI overlay style
// Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Color(0xFF1a1a1a),
systemNavigationBarIconBrightness: Brightness.light,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
@@ -229,10 +232,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
ColorScheme? darkColorScheme;
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
// Use system color schemes
// Use system color schemes, but override background to match app design
// Material You doesn't set background consistently, causing black screen issues
final (light, dark) = snapshot.data!;
lightColorScheme = light;
darkColorScheme = dark;
lightColorScheme = light.copyWith(
surface: light.surface,
background: const Color(0xFFFAFAFA), // App's preferred light background
);
darkColorScheme = dark.copyWith(
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
background: const Color(0xFF1a1a1a), // App's preferred dark background
);
} else {
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
@@ -332,8 +342,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark
? widget.darkColorScheme.background
: widget.lightColorScheme.background,
? widget.darkColorScheme.surface
: widget.lightColorScheme.surface,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
+64 -20
View File
@@ -2416,17 +2416,22 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📻 Found artist from artists array: $artistName');
}
// Check for stream_title which might contain "Artist - Title" format
// Check for stream_title which contains the actual now-playing metadata
// For radio, stream_title has "Artist - Title" format from the stream's ICY metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
// If trackTitle is still the default or matches the stream_title, update it too
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
trackTitle = parts.sublist(1).join(' - ').trim();
_logger.log('📻 Parsed from stream_title: artist=$artistName, title=$trackTitle');
}
_logger.log('📻 Parsed artist from stream_title: $artistName, title: $trackTitle');
} else {
// No separator, use stream_title as the title
trackTitle = streamTitle;
_logger.log('📻 Using stream_title as title: $trackTitle');
}
}
}
@@ -2453,14 +2458,19 @@ class MusicAssistantProvider with ChangeNotifier {
// Only cache if we don't already have better data from queue
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
// Check if existing track has proper artist (not Unknown Artist and not empty)
final existingArtist = existingTrack?.artistsString;
final existingHasProperArtist = existingTrack != null &&
existingTrack.artistsString != 'Unknown Artist' &&
!existingTrack.name.contains(' - ');
existingArtist != null &&
existingArtist != 'Unknown Artist' &&
existingArtist.trim().isNotEmpty;
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = metadata != null;
final newHasAlbum = albumName != null;
final existingHasAlbum = existingTrack?.album != null;
final newHasProperArtist = artistName != 'Unknown Artist';
// Check if new data has proper artist AND title (not malformed like "- Something")
final newTitleIsMalformed = trackTitle.startsWith('- ') || trackTitle.trim().isEmpty;
final newHasProperArtist = artistName != 'Unknown Artist' && !newTitleIsMalformed;
// For podcasts, album info is crucial (it's the podcast name)
// If new track has album but existing doesn't, prefer new or merge
@@ -2511,13 +2521,20 @@ class MusicAssistantProvider with ChangeNotifier {
}
// For selected player, _updatePlayerState() is already called above which fetches queue data
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
// Update _currentTrack if:
// - We don't have it yet
// - Podcast with new album data
// - Radio with new stream metadata (artist changed from Unknown)
final currentHasUnknownArtist = _currentTrack?.artistsString == 'Unknown Artist' ||
_currentTrack?.artistsString == null;
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
playerId == _selectedPlayer!.playerId &&
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
(_currentTrack == null ||
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
if (shouldUpdateCurrentTrack) {
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
_logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}');
}
notifyListeners();
@@ -2554,15 +2571,19 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// Check for stream_title with "Artist - Title" format
// Check for stream_title which contains the actual now-playing metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
if (title == 'Unknown Track' || title == streamTitle) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
title = parts.sublist(1).join(' - ').trim();
}
} else {
// No separator, use stream_title as the title
title = streamTitle;
}
}
}
@@ -2778,6 +2799,29 @@ class MusicAssistantProvider with ChangeNotifier {
return _cachedFavoriteTracks;
}
/// Get favorite playlists from the library
Future<List<Playlist>> getFavoritePlaylists() async {
if (_api == null) return [];
try {
return await getPlaylists(favoriteOnly: true);
} catch (e) {
_logger.log('❌ Failed to fetch favorite playlists: $e');
return [];
}
}
/// Get favorite radio stations from the library
Future<List<MediaItem>> getFavoriteRadioStations() async {
if (_api == null) return [];
try {
final stations = await _api!.getRadioStations(favoriteOnly: true);
return filterByProvider(stations);
} catch (e) {
_logger.log('❌ Failed to fetch favorite radio stations: $e');
return [];
}
}
// ============================================================================
// AUDIOBOOK HOME SCREEN ROWS
// ============================================================================
+6
View File
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
return;
}
// If queue panel is open, close it first (before collapsing player)
if (GlobalPlayerOverlay.isQueuePanelOpen) {
GlobalPlayerOverlay.closeQueuePanel();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+26
View File
@@ -13,6 +13,8 @@ import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -35,6 +37,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -73,6 +77,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
@@ -85,6 +91,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -373,6 +381,24 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
);
case 'favorite-playlists':
if (!_showFavoritePlaylists) return null;
return PlaylistRow(
key: const ValueKey('favorite-playlists'),
title: S.of(context)!.favoritePlaylists,
loadPlaylists: () => provider.getFavoritePlaylists(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-radio-stations':
if (!_showFavoriteRadioStations) return null;
return RadioStationRow(
key: const ValueKey('favorite-radio-stations'),
title: S.of(context)!.favoriteRadioStations,
loadRadioStations: () => provider.getFavoriteRadioStations(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
default:
return null;
}
+17 -146
View File
@@ -15,6 +15,7 @@ import '../widgets/artist_avatar.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../utils/search/search_scoring.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
import 'playlist_details_screen.dart';
@@ -128,6 +129,9 @@ class SearchScreenState extends State<SearchScreen> {
// PERF: Cache available filters to avoid recalculating during build
List<String>? _cachedAvailableFilters;
// Advanced search scorer with fuzzy matching, stopword removal, etc.
final SearchScorer _searchScorer = SearchScorer();
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
double _lastVerticalScrollOffset = 0;
@@ -750,151 +754,15 @@ class SearchScreenState extends State<SearchScreen> {
);
}
/// Calculate relevance score for a media item based on query match
/// Calculate relevance score for a media item based on query match.
///
/// Uses advanced scoring with:
/// - Stopword removal ("the Ramones" finds "Ramones")
/// - Fuzzy matching (typo tolerance via Jaro-Winkler)
/// - N-gram matching (partial matches)
/// - Reverse matching (query contains result name)
double _calculateRelevanceScore(MediaItem item, String query) {
final queryLower = query.toLowerCase().trim();
if (queryLower.isEmpty) return 0;
double score = 0;
final nameLower = item.name.toLowerCase();
// Primary name matching
if (nameLower == queryLower) {
score = 100; // Exact match
} else if (nameLower.startsWith(queryLower)) {
score = 80; // Starts with query
} else if (_matchesWordBoundary(nameLower, queryLower)) {
score = 60; // Contains query at word boundary
} else if (nameLower.contains(queryLower)) {
score = 40; // Contains query anywhere
} else {
score = 20; // Fuzzy/partial match (MA returned it, so some relevance)
}
// Bonus for library items (Album has inLibrary property)
if (item is Album && item.inLibrary) {
score += 10;
}
// Bonus for favorites
if (item.favorite == true) {
score += 5;
}
// Secondary field matching (artist/author/creator name)
if (item is Album) {
final artistLower = item.artistsString.toLowerCase();
if (artistLower == queryLower) {
score += 15; // Artist exact match
} else if (artistLower.contains(queryLower)) {
score += 8; // Artist contains query
}
} else if (item is Track) {
final artistLower = item.artistsString.toLowerCase();
if (artistLower == queryLower) {
score += 15;
} else if (artistLower.contains(queryLower)) {
score += 8;
}
// Also check album name for tracks
if (item.album?.name != null) {
final albumLower = item.album!.name.toLowerCase();
if (albumLower.contains(queryLower)) {
score += 5;
}
}
} else if (item is Audiobook) {
// Check audiobook author names
final authorLower = item.authorsString.toLowerCase();
if (authorLower == queryLower) {
score += 15; // Author exact match
} else if (authorLower.contains(queryLower)) {
score += 8; // Author contains query
}
// Also check narrator names
final narratorLower = item.narratorsString.toLowerCase();
if (narratorLower.contains(queryLower)) {
score += 5;
}
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
// Check podcast metadata for author/creator
final metadata = item.metadata;
bool foundCreatorMatch = false;
if (metadata != null) {
final author = (metadata['author'] as String? ?? '').toLowerCase();
final publisher = (metadata['publisher'] as String? ?? '').toLowerCase();
final owner = (metadata['owner'] as String? ?? '').toLowerCase();
final creator = (metadata['creator'] as String? ?? '').toLowerCase();
// Check all possible creator fields
final creatorFields = [author, publisher, owner, creator].where((s) => s.isNotEmpty);
bool foundExact = false;
bool foundContains = false;
for (final field in creatorFields) {
if (field == queryLower) {
foundExact = true;
break;
} else if (field.contains(queryLower)) {
foundContains = true;
}
}
if (foundExact) {
score += 15; // Creator exact match
foundCreatorMatch = true;
} else if (foundContains) {
score += 8; // Creator contains query
foundCreatorMatch = true;
}
// Also check description for keyword matches
final description = (metadata['description'] as String? ?? '').toLowerCase();
if (description.contains(queryLower)) {
score += 5; // Increased from 3 to match other media types
}
}
// Fallback: If no creator metadata matched, but query matches strongly in name,
// give a boost. Podcast names often include the host's name (e.g., "The Louis Theroux Podcast")
if (!foundCreatorMatch && nameLower.contains(queryLower)) {
// Multi-word query that matches in name suggests creator/host name
if (queryLower.contains(' ')) {
// Calculate how prominent the query is in the name
// e.g., "Louis Theroux Interviews" with query "Louis Theroux" = 14/23 = ~60%
final prominence = queryLower.length / nameLower.length;
if (prominence >= 0.5) {
score += 15; // Query is >50% of name - very strong signal
} else if (prominence >= 0.3) {
score += 12; // Query is 30-50% of name - strong signal
} else {
score += 8; // Query in name but less prominent
}
} else {
score += 5; // Single word match
}
}
}
return score;
}
/// Check if query matches at a word boundary in text
/// Handles both single-word and multi-word queries
bool _matchesWordBoundary(String text, String query) {
// For multi-word queries, check if query appears at start of a word in text
// e.g., "The Louis Theroux Podcast" contains "louis theroux" at word boundary
if (query.contains(' ')) {
// Check if text starts with query or contains query after whitespace
if (text.startsWith(query)) return true;
if (text.contains(' $query')) return true;
return false;
}
// For single-word queries, check individual words
final words = text.split(RegExp(r'\s+'));
for (final word in words) {
if (word.startsWith(query)) return true;
}
return false;
return _searchScorer.scoreItem(item, query);
}
/// Extract artists from tracks/albums that match the query but aren't in direct results
@@ -907,8 +775,10 @@ class SearchScreenState extends State<SearchScreen> {
) {
if (query.isEmpty) return [];
final queryLower = query.toLowerCase();
final queryWords = queryLower.split(RegExp(r'\s+'));
// Use normalizer to get tokens with stopwords removed
// This way "the Beatles" becomes ["beatles"] for better matching
final normalizedQuery = _searchScorer.normalizer.normalizeQuery(query);
final queryWords = normalizedQuery.tokensNoStop;
// Create a set of existing artist identifiers to avoid duplicates
final existingArtistKeys = <String>{};
@@ -942,6 +812,7 @@ class SearchScreenState extends State<SearchScreen> {
}
// Filter candidates: only include if artist name contains any query word
// (stopwords already removed from queryWords)
final crossRefArtists = <Artist>[];
for (final artist in candidateArtists.values) {
final artistLower = artist.name.toLowerCase();
+68 -5
View File
@@ -29,6 +29,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
// Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -41,6 +43,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Library settings
@@ -70,6 +73,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
// Load audiobook home row settings
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
@@ -92,6 +97,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
final showHints = await SettingsService.getShowHints();
@@ -107,6 +113,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -115,6 +123,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
});
@@ -150,6 +159,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks':
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
case 'favorite-playlists':
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
case 'favorite-radio-stations':
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
default:
return {'title': rowId, 'subtitle': ''};
}
@@ -176,6 +189,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoriteArtists;
case 'favorite-tracks':
return _showFavoriteTracks;
case 'favorite-playlists':
return _showFavoritePlaylists;
case 'favorite-radio-stations':
return _showFavoriteRadioStations;
default:
return false;
}
@@ -221,6 +238,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteTracks = value;
SettingsService.setShowFavoriteTracks(value);
break;
case 'favorite-playlists':
_showFavoritePlaylists = value;
SettingsService.setShowFavoritePlaylists(value);
break;
case 'favorite-radio-stations':
_showFavoriteRadioStations = value;
SettingsService.setShowFavoriteRadioStations(value);
break;
}
});
}
@@ -287,13 +312,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo - same size as login screen (50% of screen width)
// Use color filter to make logo dark in light theme
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
child: Theme.of(context).brightness == Brightness.light
? ColorFiltered(
colorFilter: const ColorFilter.mode(
Color(0xFF1a1a1a), // Dark color for light theme
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
)
: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
),
// Connection status box - centered with border radius like theme boxes
@@ -708,6 +746,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
onChanged: (value) {
setState(() => _volumePrecisionMode = value);
SettingsService.setVolumePrecisionMode(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
+2 -1
View File
@@ -100,7 +100,8 @@ class AuthManager {
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
+33
View File
@@ -2453,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
// ============================================================================
+43
View File
@@ -35,6 +35,8 @@ class SettingsService {
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations';
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
@@ -49,6 +51,8 @@ class SettingsService {
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
];
// View Mode Settings
@@ -84,6 +88,9 @@ class SettingsService {
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
// Volume Precision Mode Settings
static const String _keyVolumePrecisionMode = 'volume_precision_mode'; // Enable hold-to-precision volume control
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
@@ -490,6 +497,26 @@ class SettingsService {
await prefs.setBool(_keyShowFavoriteTracks, show);
}
static Future<bool> getShowFavoritePlaylists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
}
static Future<void> setShowFavoritePlaylists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoritePlaylists, show);
}
static Future<bool> getShowFavoriteRadioStations() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
}
static Future<void> setShowFavoriteRadioStations(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteRadioStations, show);
}
// Library Artists Filter - show only artists that have albums in library
static Future<bool> getShowOnlyArtistsWithAlbums() async {
final prefs = await SharedPreferences.getInstance();
@@ -824,6 +851,22 @@ class SettingsService {
await prefs.setBool(_keyHasCompletedOnboarding, completed);
}
// Volume Precision Mode Settings
/// Get whether volume precision mode is enabled (default: true)
/// When enabled, holding still while adjusting volume enters precision mode
/// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
}
/// Set whether volume precision mode is enabled
static Future<void> setVolumePrecisionMode(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyVolumePrecisionMode, enabled);
}
// Podcast Cover Cache (iTunes URLs for high-res artwork)
// Stored as JSON: {"podcastId": "itunesUrl", ...}
+15
View File
@@ -61,6 +61,7 @@ class ThemeProvider extends ChangeNotifier {
/// Get the current adaptive surface color (for bottom nav background, etc.)
/// Returns a subtle tinted surface based on the adaptive colors
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
Color? get adaptiveSurfaceColor {
if (_adaptiveColors == null) return null;
// Use the miniPlayer color but darkened for a subtle tinted background
@@ -68,6 +69,20 @@ class ThemeProvider extends ChangeNotifier {
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
}
/// Get adaptive surface color for the given brightness (light/dark mode aware)
/// Returns the same color as the expanded player background (scheme.surface)
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
// Pick scheme based on current mode
final scheme = brightness == Brightness.dark
? _adaptiveDarkScheme
: _adaptiveLightScheme;
if (scheme == null) return null;
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
return scheme.surface;
}
Future<void> _loadSettings() async {
final themeModeString = await SettingsService.getThemeMode();
_themeMode = _parseThemeMode(themeModeString);
+137
View File
@@ -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;
}
}
+96
View File
@@ -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;
}
}
+86
View File
@@ -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();
}
+333
View File
@@ -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;
}
}
+114
View File
@@ -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());
}
}
+286 -97
View File
@@ -11,6 +11,7 @@ import '../services/animation_debugger.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../l10n/app_localizations.dart';
import '../services/settings_service.dart';
import 'animated_icon_button.dart';
import 'global_player_overlay.dart';
import 'volume_control.dart';
@@ -76,6 +77,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
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
static double get _collapsedHeight => MiniPlayerLayout.height;
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
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 _isDragging = false; // True while finger is actively dragging
@@ -108,16 +115,31 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
bool _isDraggingVolume = false;
double _dragVolumeLevel = 0.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 _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
bool _isCurrentTrackFavorite = false;
String? _lastTrackUri; // Track which track we last checked favorite status for
// 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;
String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth;
// Gesture-driven expansion state
bool _isVerticalDragging = false;
@@ -176,6 +198,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_loadQueue();
} else if (status == AnimationStatus.dismissed) {
// Close queue panel when player collapses
_queuePanelController.duration = _queueCloseDuration;
_queuePanelController.reverse();
}
});
@@ -191,6 +214,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_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;
@@ -215,8 +267,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_controller.dispose();
_queuePanelController.dispose();
_slideController.dispose();
_slideOffsetNotifier.dispose();
_positionSubscription?.cancel();
_queueRefreshTimer?.cancel();
_volumePrecisionTimer?.cancel();
_progressNotifier.dispose();
_seekPositionNotifier.dispose();
super.dispose();
@@ -229,6 +283,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Animation durations - asymmetric for snappier collapse
static const Duration _expandDuration = Duration(milliseconds: 280);
static const Duration _collapseDuration = Duration(milliseconds: 200);
static const Duration _queueOpenDuration = Duration(milliseconds: 250);
static const Duration _queueCloseDuration = Duration(milliseconds: 180);
void expand() {
if (_isVerticalDragging) return;
@@ -241,9 +297,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void collapse() {
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');
// 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;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
@@ -514,14 +576,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void _toggleQueuePanel() {
if (_queuePanelController.isAnimating) return;
if (_queuePanelController.value == 0) {
_queuePanelController.duration = _queueOpenDuration;
_queuePanelController.forward();
} else {
_queuePanelController.duration = _queueCloseDuration;
_queuePanelController.reverse();
}
}
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
void _updateFavoriteStatus(dynamic currentTrack) {
if (currentTrack == null) {
@@ -771,32 +843,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final normalizedDelta = delta / containerWidth;
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
// Update peek player based on drag direction (must be inside setState to trigger rebuild)
setState(() {
_slideOffset = newSlideOffset;
if (_slideOffset != 0) {
_updatePeekPlayerState(maProvider, _slideOffset);
}
});
// Update slideOffset via ValueNotifier (no setState needed - avoids 60fps rebuilds)
_slideOffset = newSlideOffset;
// Only trigger setState when peek player changes (not every frame)
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) {
// dragDirection < 0 means swiping left (next player)
// dragDirection > 0 means swiping right (previous player)
final isNext = dragDirection < 0;
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
// Only setState when peek player actually changes (prevents unnecessary rebuilds)
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
_peekPlayer = newPeekPlayer;
// Get the peek player's current track image if available
if (_peekPlayer != null) {
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
} else {
_peekTrack = null;
_peekImageUrl = null;
}
setState(() {
_peekPlayer = newPeekPlayer;
// Get the peek player's current track image if available
if (_peekPlayer != null) {
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
} else {
_peekTrack = null;
_peekImageUrl = null;
}
});
}
}
@@ -851,9 +926,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void animateToTarget() {
if (!mounted) return;
final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
setState(() {
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
});
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
}
_slideController.addListener(animateToTarget);
@@ -879,8 +953,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Move peek content to center position (slideOffset = 0) while keeping it visible.
// The main content is hidden via _inTransition flag.
// This creates a seamless visual - peek content is already at center.
_slideOffset = 0.0; // ValueNotifier triggers AnimatedBuilder rebuild
setState(() {
_slideOffset = 0.0;
_isSliding = false;
// Keep _inTransition = true and peek data intact
});
@@ -974,19 +1048,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void animateBack() {
if (!mounted) return;
final curvedValue = Curves.easeOutBack.transform(_slideController.value);
setState(() {
_slideOffset = startOffset * (1.0 - curvedValue);
});
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
_slideOffset = startOffset * (1.0 - curvedValue);
}
_slideController.addListener(animateBack);
_slideController.duration = const Duration(milliseconds: 300);
_slideController.duration = const Duration(milliseconds: 220); // Snappier snap-back
_slideController.forward().then((_) {
if (!mounted) return;
_slideController.removeListener(animateBack);
// Update slideOffset via ValueNotifier (triggers rebuild)
_slideOffset = 0.0;
// Other state changes still need setState
setState(() {
_slideOffset = 0.0;
_isSliding = false;
_peekPlayer = null;
_peekImageUrl = null;
@@ -1040,22 +1115,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
});
}
return AnimatedBuilder(
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
builder: (context, _) {
// If no track is playing, show device selector bar
if (currentTrack == null) {
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
// Handle Android back button - close queue panel first, then collapse player
// Guard against re-entry during animations to prevent double-close
return PopScope(
canPop: !isQueuePanelOpen && !isExpanded,
onPopInvokedWithResult: (didPop, result) {
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,
);
},
),
);
},
);
@@ -1195,10 +1288,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
? adaptiveScheme.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 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
// Overlap by 2px when expanded to eliminate any subpixel rendering gaps
@@ -1276,9 +1371,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
letterSpacing: -0.5,
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 ||
_lastMeasuredTitleWidth != expandedTitleWidth ||
_cachedExpandedTitleHeight == null) {
final titlePainter = TextPainter(
text: TextSpan(text: currentTrack.name, style: titleStyle),
@@ -1287,7 +1381,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
)..layout(maxWidth: expandedTitleWidth);
_cachedExpandedTitleHeight = titlePainter.height;
_lastMeasuredTrackName = currentTrack.name;
_lastMeasuredTitleWidth = expandedTitleWidth;
}
final expandedTitleHeight = _cachedExpandedTitleHeight!;
@@ -1411,26 +1504,76 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Finish gesture-driven expansion
_handleVerticalDragEnd(details);
},
onVerticalDragCancel: () {
// Reset state if gesture is cancelled (e.g., system takes over)
_isVerticalDragging = false;
},
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
// Start volume drag if device reveal is visible
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(() {
_isDraggingVolume = true;
_dragVolumeLevel = currentVolume;
_dragVolumeLevel = startVolume;
});
_lastVolumeDragPosition = details.globalPosition;
HapticFeedback.lightImpact();
}
},
onHorizontalDragUpdate: (details) {
// Volume swipe when device reveal is visible
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
// Directional volume: drag right = increase, drag left = decrease
// Multiple swipes accumulate - each starts from current volume
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / collapsedWidth;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
// Check for stillness to trigger precision mode (only if enabled in settings)
final currentPosition = details.globalPosition;
if (_volumePrecisionModeEnabled && _lastVolumeDragPosition != null) {
final movement = (currentPosition - _lastVolumeDragPosition!).distance;
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) {
setState(() {
_dragVolumeLevel = newVolume;
@@ -1462,6 +1605,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (_isDraggingVolume) {
// Send final volume on release
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(() {
_isDraggingVolume = false;
});
@@ -1493,6 +1640,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_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(
// Use foregroundDecoration for border so it renders ON TOP of content
// This prevents the album art from clipping the yellow synced border
@@ -1506,7 +1671,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
elevation: _lerpDouble(4, 0, t),
shadowColor: Colors.black.withOpacity(0.3),
shadowColor: _shadowColor,
// 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,
@@ -1537,18 +1702,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// 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 progressAreaWidth = width - _collapsedArtSize;
return ClipRRect(
borderRadius: BorderRadius.only(
topRight: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
),
clipBehavior: Clip.hardEdge,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: progressAreaWidth * progress,
height: height,
color: collapsedBg.withOpacity(progressOpacity),
// RepaintBoundary isolates frequent progress updates from parent animation
return RepaintBoundary(
child: ClipRRect(
borderRadius: BorderRadius.only(
topRight: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
),
clipBehavior: Clip.hardEdge,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: progressAreaWidth * progress,
height: height,
color: collapsedBg.withOpacity(progressOpacity),
),
),
),
);
@@ -2118,19 +2286,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Favorite button (expanded only) - hide when queue panel is open
// GPU PERF: Use icon color alpha instead of Opacity
// Scale pop animation when toggling favorite
if (t > 0.3 && queueT < 0.5)
Positioned(
top: topPadding + 4,
right: 52,
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,
child: TweenAnimationBuilder<double>(
key: ValueKey(_isCurrentTrackFavorite),
tween: Tween(begin: 1.3, end: 1.0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
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),
),
),
@@ -2213,24 +2392,32 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Volume swipe overlay (covers entire mini player when dragging volume)
// Only visible when device reveal is open and user is dragging
// Positioned last in Stack so it renders ON TOP of all content
if (_isDraggingVolume && t < 0.5)
// Uses AnimatedOpacity for smooth fade in/out transition
if (t < 0.5)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: Stack(
children: [
// Unfilled (darker) background
Container(color: collapsedBgUnplayed),
// Filled (lighter) portion based on volume
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
heightFactor: 1.0,
child: Container(color: collapsedBg),
),
child: IgnorePointer(
ignoring: !_isDraggingVolume,
child: AnimatedOpacity(
opacity: _isDraggingVolume ? 1.0 : 0.0,
duration: const Duration(milliseconds: 120),
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: Stack(
children: [
// Unfilled (darker) background
Container(color: collapsedBgUnplayed),
// Filled (lighter) portion based on volume
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
heightFactor: 1.0,
child: Container(color: collapsedBg),
),
),
],
),
],
),
),
),
),
@@ -2343,11 +2530,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
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(
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
color: Color.lerp(colorScheme.surfaceVariant, expandedBgColor, t),
child: Icon(
Icons.music_note_rounded,
color: Color.lerp(colorScheme.onSurfaceVariant, Colors.white24, t),
color: Color.lerp(colorScheme.onSurfaceVariant, expandedIconColor, t),
size: _lerpDouble(24, 120, t),
),
);
@@ -2527,12 +2717,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
return SizedBox(
width: 44,
height: 44,
child: IconButton(
icon: Icon(icon),
child: AnimatedIconButton(
icon: icon,
color: color,
iconSize: 22,
onPressed: onPressed,
padding: EdgeInsets.zero,
),
);
}
+13 -2
View File
@@ -99,6 +99,15 @@ class GlobalPlayerOverlay extends StatefulWidget {
static bool get isPlayerExpanded =>
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)
static double get expansionProgress =>
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
@@ -483,8 +492,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
? themeProvider.adaptiveSurfaceColor!
// Use brightness-aware method to respect light/dark mode
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
final baseBgColor = (themeProvider.adaptiveTheme && adaptiveBg != null)
? adaptiveBg
: colorScheme.surface;
return ValueListenableBuilder<PlayerExpansionState>(
+158 -10
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
import '../../services/debug_logger.dart';
import '../../services/settings_service.dart';
final _volumeLogger = DebugLogger();
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
@@ -23,10 +28,16 @@ class PlayerCard extends StatefulWidget {
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<bool>? onPrecisionModeChanged;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
// Precision mode settings
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
const PlayerCard({
super.key,
required this.player,
@@ -43,6 +54,7 @@ class PlayerCard extends StatefulWidget {
this.onSkipNext,
this.onPower,
this.onVolumeChange,
this.onPrecisionModeChanged,
});
@override
@@ -54,7 +66,69 @@ class _PlayerCardState extends State<PlayerCard> {
double _dragVolumeLevel = 0.0;
double _cardWidth = 0.0;
int _lastVolumeUpdateTime = 0;
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
bool _precisionModeEnabled = true; // From settings
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
double _precisionStartX = 0.0; // Finger X position when precision mode started
@override
void initState() {
super.initState();
_loadPrecisionModeSetting();
}
Future<void> _loadPrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = enabled;
});
}
}
@override
void dispose() {
_precisionTimer?.cancel();
super.dispose();
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
_precisionStartX = _lastLocalX; // Capture finger position at entry
});
widget.onPrecisionModeChanged?.call(true);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
context: 'Volume',
);
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (!_inPrecisionMode) return;
setState(() {
_inPrecisionMode = false;
});
widget.onPrecisionModeChanged?.call(false);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: EXITED',
context: 'Volume',
);
}
@override
Widget build(BuildContext context) {
@@ -67,7 +141,10 @@ class _PlayerCardState extends State<PlayerCard> {
final filledColor = widget.backgroundColor;
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) {
_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) {
// Get current volume from player
final currentVolume = (widget.player.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 - _lastDragEndTime;
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
// Use local volume if:
// 1. We have a local override (we've swiped before) AND
// 2. We're within the consecutive swipe window (5 seconds)
// Otherwise, read fresh from player state
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
final startVolume = useLocalVolume
? _dragVolumeLevel // Continue from where last swipe ended
: playerVolume; // Fresh from player
_volumeLogger.debug(
'DRAG_START [${widget.player.name}]: '
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
'localVol=${(_dragVolumeLevel * 100).round()}%, '
'playerVol=${(playerVolume * 100).round()}%, '
'startVol=${(startVolume * 100).round()}%',
context: 'Volume',
);
setState(() {
_isDraggingVolume = true;
_dragVolumeLevel = currentVolume;
_dragVolumeLevel = startVolume;
});
_lastDragPosition = details.globalPosition;
HapticFeedback.lightImpact();
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDraggingVolume || _cardWidth <= 0) return;
// Directional volume: drag right = increase, drag left = decrease
// Distance determines amount of change (full card width = 100%)
// Multiple swipes accumulate - each starts from current volume
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
// Track local X position for precision mode
_lastLocalX = details.localPosition.dx;
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
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
@@ -319,7 +455,17 @@ class _PlayerCardState extends State<PlayerCard> {
void _onDragEnd(DragEndDetails details) {
// Send final volume on release
_volumeLogger.debug(
'DRAG_END [${widget.player.name}]: '
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
'precisionMode=$_inPrecisionMode',
context: 'Volume',
);
widget.onVolumeChange?.call(_dragVolumeLevel);
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
@@ -327,6 +473,8 @@ class _PlayerCardState extends State<PlayerCard> {
}
void _onDragCancel() {
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
@@ -449,6 +449,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Use the elastic animation value directly (already has bounce)
final slideOffset = distanceToTravel * (1.0 - t);
// Darken cards when:
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
+300 -121
View File
@@ -1,12 +1,14 @@
import 'package:flutter/material.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 '../../models/player.dart';
import '../../theme/design_tokens.dart';
import '../common/empty_state.dart';
/// Panel that displays the current playback queue
class QueuePanel extends StatelessWidget {
/// Panel that displays the current playback queue with drag-to-reorder
/// and swipe-left-to-delete functionality
class QueuePanel extends StatefulWidget {
final MusicAssistantProvider maProvider;
final PlayerQueue? queue;
final bool isLoading;
@@ -31,49 +33,145 @@ class QueuePanel extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
onPressed: onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: onRefresh,
padding: Spacing.paddingAll12,
),
],
),
),
State<QueuePanel> createState() => _QueuePanelState();
}
// Queue content
Expanded(
child: isLoading
? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty
? _buildEmptyState(context)
: _buildQueueList(),
),
],
class _QueuePanelState extends State<QueuePanel> {
final AnimatedListController _listController = AnimatedListController();
List<QueueItem> _items = [];
// Track pointer for right-swipe-to-close (bypasses gesture arena)
Offset? _pointerStart;
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() {
final currentIndex = queue!.currentIndex ?? 0;
final items = queue!.items;
final currentIndex = widget.queue!.currentIndex ?? 0;
return ListView.builder(
return AutomaticAnimatedListView<QueueItem>(
list: _items,
padding: Spacing.paddingH8,
cacheExtent: 500,
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
comparator: AnimatedListDiffListComparator<QueueItem>(
sameItem: (a, b) => a.queueItemId == b.queueItemId,
sameContent: (a, b) => a.track.name == b.track.name && a.track.duration == b.track.duration,
),
itemBuilder: (context, item, data) {
final index = _items.indexOf(item);
final isCurrentItem = index == currentIndex;
final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
return RepaintBoundary(
child: Opacity(
opacity: isPastItem ? 0.5 : 1.0,
child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
decoration: BoxDecoration(
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
),
child: ListTile(
dense: true,
leading: ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: SizedBox(
width: 44,
height: 44,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
memCacheWidth: 176,
memCacheHeight: 176,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
errorWidget: (context, url, error) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
)
: Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
// Always return same widget structure (with Dismissible) for consistency
// Only vary the measuring flag for image loading optimization
return _buildDismissibleQueueItem(item, index, isCurrentItem, isPastItem, measuring: data.measuring);
},
listController: _listController,
reorderModel: AnimatedListReorderModel(
onReorderStart: (index, dx, dy) => true,
onReorderFeedback: (index, dropIndex, offset, dx, dy) => null,
onReorderMove: (index, dropIndex) => true,
onReorderComplete: (index, dropIndex, slot) {
_handleReorder(index, dropIndex);
return true;
},
),
addLongPressReorderable: false, // We use custom drag handle instead
);
}
Widget _buildDismissibleQueueItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {bool measuring = false}) {
return Dismissible(
key: ValueKey(item.queueItemId),
direction: DismissDirection.endToStart, // Swipe left to delete only
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Colors.red.shade700,
borderRadius: BorderRadius.zero,
),
child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
),
confirmDismiss: (direction) async {
// Don't allow deleting currently playing track
return !isCurrentItem;
},
onDismissed: (direction) {
_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,
style: TextStyle(
color: isCurrentItem ? primaryColor : textColor,
fontSize: 14,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
title: Text(
item.track.name,
style: TextStyle(
color: isCurrentItem ? widget.primaryColor : widget.textColor,
fontSize: 14,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
? Text(
item.track.artists!.first.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
? Text(
item.track.artists!.first.name,
style: TextStyle(
color: widget.textColor.withOpacity(0.6),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (duration.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
duration,
style: TextStyle(
color: textColor.withOpacity(0.6),
color: widget.textColor.withOpacity(0.5),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: isCurrentItem
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
: null,
onTap: () {
// TODO: Jump to this track in queue
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it
// Alternative: Could use multiple next() calls but that's inefficient and unreliable
},
),
),
if (isCurrentItem)
Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20)
else if (!measuring)
Builder(
builder: (itemContext) => GestureDetector(
onVerticalDragStart: (details) {
_listController.notifyStartReorder(
itemContext,
details.localPosition.dx,
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
},
),
),
);
},
),
),
);
}
}
+122
View File
@@ -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),
),
),
],
),
),
);
}
}
+165
View File
@@ -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,
),
),
);
},
),
),
],
),
);
}
}
+105
View File
@@ -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,
),
),
),
),
],
),
),
);
}
}
+165
View File
@@ -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
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.8.1-beta+38
version: 2.8.5-beta+43
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -49,6 +49,9 @@ dependencies:
# Raw PCM Audio Playback (for Sendspin streaming)
flutter_pcm_sound: ^3.3.3
# Animated list with drag-to-reorder
great_list_view: ^0.2.3
dev_dependencies:
flutter_test:
sdk: flutter