Compare commits

...
79 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
Your NameandClaude Opus 4.5 aa74fe96da Bump version to 2.8.1-beta+38
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:15:07 +00:00
Your NameandClaude Opus 4.5 3c296e743d Merge feature/provider-filter-sync: Add provider and player filter sync
Syncs provider_filter and player_filter from Music Assistant user settings.
Filters library content, search results, and available players based on
the user's MA profile configuration.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:09:57 +00:00
Your NameandClaude Opus 4.5 79cadff853 Add player filter and debug logging for recent albums
- Sync player_filter from MA user settings (like provider_filter)
- Apply provider filtering to recent albums
- Add debug logging to diagnose recently_played_items API response

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:39:53 +00:00
Your NameandClaude Opus 4.5 19b653a05c Add player_filter sync from MA user settings
Syncs player_filter from Music Assistant auth/me endpoint alongside
the existing provider_filter. Filters which players are visible to
the user based on their MA profile settings.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:19:20 +00:00
Your Name 50e567c411 fix: Make favoriteOnly nullable in getAudiobooks wrapper 2026-01-05 11:59:31 +00:00
Your NameandClaude Opus 4.5 26e2c12f8c feat: Add provider filter sync from Music Assistant
- Read provider_filter from auth/me response
- Store and apply filter in MusicAssistantProvider
- Filter library items (artists, albums, tracks, radio, podcasts)
- Filter search results
- Add wrapper methods for playlists and audiobooks with filtering
- Update screens to use filtered wrapper methods

Empty filter = all providers visible (no filtering)
Non-empty filter = only show items from allowed providers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 11:47:43 +00:00
Your NameandClaude Opus 4.5 de8797a48d Invalidate detail screen caches on library changes
When adding/removing items from library, invalidate relevant caches
to prevent stale inLibrary status in detail screens:

- Albums: invalidate artist albums, home album caches
- Tracks: invalidate all album/playlist tracks caches
- Artists: invalidate home artist caches

Fix stale database cache issue:
- Add clearCacheForType() - clears cache type before re-syncing
- Add markCachedItemDeleted() - marks specific item as deleted
- Call markCachedItemDeleted() immediately when removing from library
- This ensures removed items don't reappear from database cache

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:56:24 +00:00
Your NameandClaude Opus 4.5 e1acdb27b3 Fix: Use forceLibrarySync when artist filter setting changes
loadLibrary() loads from cache first then syncs in background,
causing the UI to show stale data until manual refresh.

forceLibrarySync() forces an immediate sync, bypassing the 5-minute
cache freshness check and ensuring the new filter is applied immediately.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 02:42:47 +00:00
Your NameandClaude Opus 4.5 dbf066c692 Use API-level filtering for artist library instead of UI filtering
Changed the artist filter setting to use the Music Assistant API's
built-in albumArtistsOnly parameter instead of filtering in the UI.

When "Show only artists with albums" is enabled:
- sync_service.dart passes albumArtistsOnly: true to getArtists()
- library_provider.dart passes albumArtistsOnly: true to getArtists()
- Settings screen triggers loadLibrary() when setting changes

Removed the unreliable UI-level filtering that was causing the Selector
widget to not properly rebuild when the setting changed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 02:28:40 +00:00
Your NameandClaude Opus 4.5 f5445db8d4 Fix artist filter toggle not triggering UI rebuild
The Selector widget was blocking rebuilds because it only watched
provider data, but _showOnlyArtistsWithAlbums is a local state
variable. Added a ValueKey that includes the setting value to force
Selector to rebuild when the setting changes.

Also made the settings save async and added verification logging.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 02:12:22 +00:00
Your Name 038df11112 fix: reload artist filter on tab change and return from settings 2026-01-05 01:54:19 +00:00
Your Name 63d857ca2c fix: read artist filter setting fresh on each tab view 2026-01-05 01:38:19 +00:00
Your Name c382822f72 fix: reload artist filter setting when returning to library 2026-01-05 01:25:05 +00:00
Your Name 1d37502f44 debug: add more logging for artist filter setting 2026-01-05 01:22:26 +00:00
Your Name 8396078f94 debug: use DebugLogger for artist filter logging 2026-01-05 01:08:11 +00:00
Your Name b051e5af0c debug: add in-app debug banner for artist filter 2026-01-05 01:06:11 +00:00
Your Name f0bce58cfa debug: add logging for artist filter 2026-01-05 01:04:20 +00:00
Your NameandClaude Opus 4.5 c563b1ffaa feat: add podcast action buttons and artist filter setting
Podcast Detail Screen:
- Added action buttons row matching album/artist detail screens
- Play button plays most recent episode
- Play on... button shows device selector for most recent episode
- Library button remains for add/remove from library

Settings:
- Added new "Library" section in settings
- Added "Show only artists with albums" toggle
- When enabled, hides artists that have no albums in library

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:49:08 +00:00
Your NameandClaude Opus 4.5 8876a8ba83 fix: resolve undefined maProvider compile error
Changed maProvider.getImageUrl to context.read<MusicAssistantProvider>().getImageUrl
at line 620 where maProvider was no longer defined after switching from context.watch()
to context.select() for Hero animation performance.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:28:07 +00:00
Your NameandClaude Opus 4.5 6e5ab73e8e Fix Hero animations: context.select, memCacheWidth, precaching
Major Hero animation performance improvements based on deep audit:

1. Fix context.watch → context.select (CRITICAL)
   - podcast_detail_screen: Changed from context.watch() to context.select()
     to prevent rebuilds during Hero animation when provider updates
   - artist_details_screen: Delayed provider listener attachment until
     after Hero animation completes (350ms)

2. Add memCacheWidth/memCacheHeight consistently
   - podcast library list view: Added 256x256 cache
   - podcast library grid view: Added 256x256 cache
   - podcast_detail_screen: Added 256x256 cache
   - audiobook_detail_screen: Added 256x256 cache
   - artist_details_screen: Added 256x256 cache + placeholder

3. Fix album_details_screen Hero structure
   - Changed from Container(BoxDecoration) to ClipRRect → Container pattern
   - Matches source (album_card.dart) structure exactly
   - Fixed borderRadius 16 → 12 to match source
   - Added memCacheWidth 256 and zero fade durations

4. Add image precaching to audiobook_row
   - Added _precacheAudiobookImages() method
   - Precaches first 10 visible items after data loads
   - Matches pattern used by album_row (which works smoothly)

Root causes identified by 5 parallel subagents:
- context.watch() caused full rebuilds during animation
- Missing memCacheWidth caused inconsistent image decode sizes
- Mismatched Hero widget structures between source/destination
- Missing precaching meant images decoded during animation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:16:49 +00:00
Your NameandClaude Opus 4.5 206ab7bff0 Revert background colors and fix search podcast Hero animation
- Reverted colorScheme.surface back to colorScheme.background in 11 files
  (user reported the new color was too light/grey)

- Fixed search screen podcast Hero structure to match library/detail pattern:
  - Changed widget order from Container → ClipRRect to ClipRRect → Container
  - Changed borderRadius from 8 to 16 (matching library/detail)
  - Changed color from surfaceVariant to surfaceContainerHighest
  - Removed memCacheWidth constraints to match library behavior
  - This ensures smooth Hero animation when navigating from search

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:54:50 +00:00
Your NameandClaude Opus 4.5 8d07752a03 Audit: Fix deprecated APIs and optimize scroll performance
- Replace colorScheme.background with colorScheme.surface (Material 3)
- Add memCacheWidth/memCacheHeight to CachedNetworkImage instances
- Create AUDIT_REPORT.md with comprehensive findings
- Create AUDIT_SUMMARY.md with changes applied

Files modified: 11 screen files
Deprecated API fixes: 20 instances
Image cache optimizations: 6 locations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:29:04 +00:00
Your NameandClaude Opus 4.5 0843b8f9ce Remove verbose debug logging
Cleaned up excessive logging throughout the codebase:
- Removed library add/remove action logs
- Removed media event subscription logs
- Removed library refresh status logs
- Removed podcast/radio search logs
- Removed audiobook browse debug dumps
- Removed favorites/library operation logs from screens
- Simplified error-only logging pattern

Note: Radio tab already has pull-to-refresh implemented.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:04:30 +00:00
Your NameandClaude Opus 4.5 5d8d7e4713 Add event-driven library sync via MA server events
Previously, library updates relied only on optimistic UI updates and
API response timing. This caused sync issues when MA operations timed
out (operations often succeed but take >30 seconds).

Now subscribing to MA server events for real-time library sync:
- media_item_added: Triggers library refresh when items added
- media_item_deleted: Triggers library refresh when items removed

Also added:
- Provider listener in artist details screen for reactive updates
- Debug logging for media events in API layer
- Event handlers that invalidate caches and refresh library data

This ensures the UI stays in sync even when API calls timeout,
since MA server sends events when operations complete.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:33:44 +00:00
Your NameandClaude Opus 4.5 5320ec631a Make library buttons update instantly with optimistic UI
Previously, library buttons waited for API response before updating.
This caused issues because:
1. MA operations can take >5 seconds and timeout
2. Button appeared stuck even when operation succeeded
3. No snackbar feedback until API completed

Now using optimistic updates:
- UI updates immediately when button is tapped
- Snackbar shows instantly
- API call runs in background
- Only revert on actual errors, not timeouts
  (timeouts usually mean MA is still processing successfully)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:48:53 +00:00
Your NameandClaude Opus 4.5 c7aea782ae Fix library button getting stuck after navigation
Two issues fixed:

1. "Not found in library" error handling:
   - When trying to remove an item that's already removed, MA returns
     "not found in library" error
   - Now we treat this as success (item is already not in library)
   - Button correctly updates to "add to library" state

2. Search cache invalidation:
   - Added invalidateSearchCache() method to CacheService
   - Called when items are added or removed from library
   - Ensures next search shows correct library status
   - Fixes issue where button state was wrong after navigating back

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:24:42 +00:00
Your NameandClaude Opus 4.5 08566d2b4d Fix Track constructor - remove invalid discNumber/trackNumber params
Track class doesn't have discNumber or trackNumber constructor parameters.
These properties are accessed through metadata, not as direct params.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:04:01 +00:00
Your NameandClaude Opus 4.5 177d9389c5 Add instant UI updates for favorites and library changes
Previously, toggling favorites or adding/removing from library required
a manual refresh to see changes. Now updates are instant:

Favorites:
- After toggle, local cache is updated with new favorite status
- notifyListeners() triggers immediate UI rebuild

Library:
- Remove: Item is removed from local cache instantly
- Add: Triggers background refresh (500ms delay) to fetch new item

This provides a responsive UX without requiring manual pull-to-refresh.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:52:34 +00:00
Your NameandClaude Opus 4.5 fc20309642 Fix library not showing all artists (only album artists)
The getArtists() API defaulted to albumArtistsOnly=true, which filtered
out artists that were added to library but don't have albums there.

Changed to albumArtistsOnly=false in:
- music_assistant_provider.dart loadArtists()
- library_provider.dart loadLibrary() and loadArtists()
- sync_service.dart background sync

Now artists like Ramones will appear in Library even if you only
added the artist (not their albums).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:46:01 +00:00
Your NameandClaude Opus 4.5 07cb1f7f95 Fix library button UI not updating after add/remove
The API calls were working (MA confirming with events), but the UI
wasn't updating because _isInLibrary() still checked the unchanged
item data.

Added local state tracking:
- _addedToLibrary and _removedFromLibrary Sets track changes in session
- _isInLibrary() checks local state first, then falls back to server data
- Add/remove handlers update tracking sets on success

Now buttons visually update immediately when library status changes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:37:31 +00:00
Your NameandClaude Opus 4.5 69bf28882d Fix library add/remove buttons not working properly
The library add button was failing because when items already had a
library mapping, the code was falling back to the library provider
instead of using external providers like Spotify.

Changes:
- Fixed _addToLibrary in search_screen.dart to ALWAYS prefer non-library
  providers (ignoring availability flag which only affects playback)
- Applied same fix to artist_details_screen.dart, album_details_screen.dart,
  and podcast_detail_screen.dart
- Added proper handling for library-only items (show message instead of
  sending invalid request)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:20:31 +00:00
Your NameandClaude Opus 4.5 fba7091997 Fix: use playArtistRadio instead of playRadio for Artist type
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:53:35 +00:00
Your NameandClaude Opus 4.5 cdc6c5b11b Add library management and expandable actions for search results
- Fix URI bug: use providerDomain instead of providerInstance for library operations
- Add expandable dropdown menus for all search result types (long-press to reveal)
- Implement action buttons per media type:
  - Artist: Radio, Queue, Favorite, Library
  - Album: Play, Play On, Queue, Favorite, Library
  - Playlist: Play, Play On, Library
  - Audiobook: Play, Play On, Favorite, Library
  - Radio/Podcast: Play, Play On, Library
- Add library toggle button to Artist, Album, and Podcast detail screens
- Add API methods: playAlbum, addAlbumToQueue, addArtistToQueue, playPlaylist
- Add localization strings for playingAlbum and playingPlaylist

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:42:19 +00:00
Your NameandClaude Opus 4.5 3edb6af5a9 Add "Add to Library" button for search results
- Add music/library/add_item and music/library/remove_item API endpoints
- Add addToLibrary/removeFromLibrary methods in provider
- Add library_add icon button to search tiles (artist, album, playlist, audiobook, radio, podcast)
- Button only shows for items not already in library
- Add localization strings for library actions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:57:31 +00:00
Your NameandClaude Opus 4.5 cded364f1b Fix podcast, audiobook, and radio player issues
- Mini player buttons: Show play/skip 30s/power for podcasts/audiobooks when player list is expanded
- Fixed gap between expanded player and nav bar:
  - Use viewPadding.bottom to match BottomNavigationBar's calculation
  - Use Clip.hardEdge when expanded to avoid anti-alias edge artifacts
  - Add 2px overlap to eliminate any remaining subpixel rendering gaps
- Fixed stream quality overlapping chapter info in audiobook expanded player
- Added skip 10 seconds buttons (±10s) to expanded audiobook and podcast controls only
- Skip 10s and 30s buttons now use the same size for visual balance
- Removed verbose podcast name logging causing white flash behind mini player
- Fixed radio streams showing "Unknown Artist" - now extracts artist from stream metadata
- Added letter scrollbar to Podcasts and Radio library views

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:32:31 +00:00
47 changed files with 6512 additions and 1145 deletions
+16 -1
View File
@@ -35,6 +35,16 @@ jobs:
channel: 'stable' channel: 'stable'
cache: true cache: true
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Get dependencies - name: Get dependencies
run: flutter pub get run: flutter pub get
@@ -65,7 +75,12 @@ jobs:
EOF EOF
- name: Build APK - name: Build APK
run: flutter build apk --release run: |
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+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)
+119
View File
@@ -0,0 +1,119 @@
# Ensemble Codebase Audit Prompt
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
---
## Prompt
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
**CRITICAL RULES:**
1. DO NOT remove or break any existing functionality
2. All changes must be on the current branch: `audit/code-quality-review`
3. Run `flutter analyze` after each set of changes to verify no regressions
4. Commit changes in logical, reviewable chunks
**AUDIT PHASES:**
### Phase 1: Code Quality Analysis (Read-Only)
First, analyze the codebase without making changes. Create a report covering:
1. **Large File Analysis** - Review these files for refactoring opportunities:
- `lib/screens/new_library_screen.dart` (122KB)
- `lib/screens/search_screen.dart` (129KB)
- `lib/widgets/expandable_player.dart` (107KB)
- `lib/screens/album_details_screen.dart` (48KB)
- `lib/screens/artist_details_screen.dart` (45KB)
2. **Pattern Inconsistencies** - Look for:
- Inconsistent naming conventions (camelCase vs snake_case)
- Mixed widget composition patterns
- Inconsistent error handling approaches
- Duplicate code across screens/widgets
3. **Performance Red Flags** - Identify:
- Unnecessary rebuilds (missing const constructors)
- Heavy computations in build methods
- Missing list item caching (ListView.builder vs ListView)
- Inefficient state management patterns
4. **Scroll Performance Issues** - Specifically audit:
- ScrollController usage and disposal
- Sliver implementations
- AnimatedBuilder/AnimatedWidget usage
- Image loading in scrollable lists
- Any jank-inducing patterns
### Phase 2: Quick Wins (Low Risk, High Impact)
Apply these fixes first:
1. Add missing `const` constructors throughout
2. Fix any `flutter analyze` warnings
3. Remove dead code and unused imports
4. Standardize formatting with `dart format`
### Phase 3: Scroll Animation Optimization
Focus on smoother scrolling:
1. **Image Optimization**
- Ensure CachedNetworkImage is used consistently
- Add appropriate cacheWidth/cacheHeight
- Implement proper placeholder/error widgets
2. **List Performance**
- Convert any ListView to ListView.builder where appropriate
- Add itemExtent where possible for fixed-height items
- Implement AutomaticKeepAliveClientMixin where needed
- Consider RepaintBoundary for complex list items
3. **Animation Smoothing**
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
- Ensure animations run at 60fps (avoid heavy computations during animation)
- Review CustomScrollView/Sliver implementations
### Phase 4: Code Consistency Improvements
1. **Widget Extraction**
- Extract repeated UI patterns into reusable widgets
- Ensure extracted widgets follow existing naming patterns
2. **State Management**
- Verify Provider usage is consistent
- Check for proper disposal of controllers/streams
3. **Error Handling**
- Standardize try/catch patterns
- Ensure consistent loading/error state UI
### Phase 5: Verification
After all changes:
1. Run `flutter analyze` - must pass with 0 issues
2. Run `flutter test` if tests exist
3. Create a summary of all changes made
4. List any recommendations that were NOT implemented (too risky)
---
## Execution Instructions
```bash
# Start the audit
cd /home/home-server/Ensemble
git checkout audit/code-quality-review
# After each phase, commit your changes
git add -A && git commit -m "Audit Phase X: [description]"
# Verify no regressions
flutter analyze
```
---
## Expected Deliverables
1. `AUDIT_REPORT.md` - Findings from Phase 1
2. Multiple commits with clear messages for each phase
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
+136
View File
@@ -0,0 +1,136 @@
# Ensemble Flutter App - Code Audit Report
**Date:** January 4, 2026
**Branch:** `audit/code-quality-review`
**Files Audited:** 93 Dart files
---
## Executive Summary
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
| Category | Issues Found | Priority |
|----------|-------------|----------|
| Deprecated APIs | 20 instances | HIGH |
| Missing const constructors | 100+ instances | MEDIUM |
| Image cache parameters | 7 locations | MEDIUM |
| Code duplication | 8 view mode functions | LOW |
| Color constants | 3 files with duplicates | LOW |
---
## 1. CRITICAL: Deprecated API Usage
### `colorScheme.background` (20 instances)
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
**Files affected:**
- lib/main.dart:335, 336
- lib/screens/library_artists_screen.dart:21
- lib/screens/new_home_screen.dart:121
- lib/screens/artist_details_screen.dart:708, 720
- lib/screens/library_playlists_screen.dart:53
- lib/screens/library_tracks_screen.dart:19
- lib/screens/login_screen.dart:489
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
- lib/screens/album_details_screen.dart:807, 819
- lib/screens/library_albums_screen.dart:19
- lib/screens/search_screen.dart:427, 738, 739
- lib/screens/settings_screen.dart:245
---
## 2. HIGH: Missing const Constructors
### EdgeInsets.zero (64 instances)
High-impact optimization - used extensively in button padding.
**Key files:**
- lib/screens/album_details_screen.dart (12 instances)
- lib/screens/artist_details_screen.dart (8 instances)
- lib/screens/audiobook_detail_screen.dart (3 instances)
- lib/screens/audiobook_series_screen.dart (2 instances)
- lib/screens/audiobook_author_screen.dart (2 instances)
### BorderRadius.circular (40+ instances)
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
---
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
### CachedNetworkImage without memCacheWidth/Height
**lib/screens/new_library_screen.dart:**
- Line 1395-1402: Author image (48x48) - missing cache params
- Line 1480-1486: Author card image - missing cache params
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
- Line 1740-1745: Audiobook grid cover - missing cache params
- Line 1959-1969: Playlist cover (56x56) - missing cache params
- Line 2143-2152: Playlist grid covers - missing cache params
- Line 2973-2981: Album cover (56x56) - missing cache params
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
---
## 4. LOW: Code Duplication
### View Mode Cycling Functions (8 nearly identical functions)
**File:** lib/screens/new_library_screen.dart
Functions that follow identical switch/case pattern:
- `_cycleArtistsViewMode()` (line 184)
- `_cycleAlbumsViewMode()` (line 200)
- `_cyclePlaylistsViewMode()` (line 216)
- `_cycleAuthorsViewMode()` (line 232)
- `_cycleAudiobooksViewMode()` (line 248)
- `_cycleSeriesViewMode()` (line 286)
- `_cycleRadioViewMode()` (line 303)
- `_cyclePodcastsViewMode()` (line 319)
**Recommendation:** Create generic utility function.
### Duplicated Color Constants (3 files)
Series fallback colors defined identically in:
- lib/widgets/series_row.dart:246-253
- lib/screens/audiobook_series_screen.dart:466-473
- lib/screens/new_library_screen.dart:2112-2119
**Recommendation:** Extract to lib/constants/colors.dart
---
## 5. POSITIVE FINDINGS (Already Optimized)
- **RepaintBoundary**: Properly implemented in all list item widgets
- **ListView.builder**: Used consistently with cacheExtent: 1000
- **ScrollController disposal**: All 8+ controllers properly disposed
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
- **Debounced operations**: Color extraction debounced to 300ms
---
## Recommended Action Plan
### Phase 1: Quick Wins (Low Risk)
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
2. Add `const` to EdgeInsets.zero instances
### Phase 2: Image Optimization
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
### Phase 3: Code Cleanup (Optional)
1. Extract view mode cycling to utility function
2. Centralize color constants
---
## Files by Complexity
| File | Lines | Status |
|------|-------|--------|
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
+57
View File
@@ -0,0 +1,57 @@
# Audit Summary
**Branch:** `audit/code-quality-review`
**Date:** January 4, 2026
## Changes Applied
### Phase 1: Analysis Complete
- Created `AUDIT_REPORT.md` with comprehensive findings
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
### Phase 2: Deprecated API Fixes (18 files)
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
**Files modified:**
- lib/screens/library_artists_screen.dart
- lib/screens/library_albums_screen.dart
- lib/screens/library_playlists_screen.dart
- lib/screens/library_tracks_screen.dart
- lib/screens/artist_details_screen.dart
- lib/screens/new_home_screen.dart
- lib/screens/login_screen.dart
- lib/screens/settings_screen.dart
- lib/screens/album_details_screen.dart
- lib/screens/search_screen.dart
- lib/screens/new_library_screen.dart
### Phase 3: Scroll Performance Optimization
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
**lib/screens/new_library_screen.dart:**
- Line 1400-1401: Author list tile image (128x128 cache)
- Line 1487-1488: Author grid card image (256x256 cache)
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
- Line 1749-1750: Audiobook grid cover (512x512 cache)
- Line 1970-1971: Series list tile cover (256x256 cache)
- Line 2986-2987: Album list tile cover (256x256 cache)
## Recommendations NOT Implemented (Low Priority)
These were identified but NOT changed to preserve functionality:
1. **View mode cycling refactor** - 8 similar functions could be consolidated
2. **Color constant extraction** - Duplicated colors in 3 files
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
4. **BorderRadius.circular const** - 40+ instances
## Verification
Changes should be verified by:
1. GitHub Actions build (triggered automatically)
2. Manual UI testing of scroll smoothness
3. Verify no visual regressions in dark/light themes
## Build Trigger
GitHub Actions workflow triggered on branch `audit/code-quality-review`
+6
View File
@@ -2,6 +2,9 @@ buildscript {
ext.kotlin_version = '1.9.10' ext.kotlin_version = '1.9.10'
repositories { repositories {
google() google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral() mavenCentral()
} }
@@ -14,6 +17,9 @@ buildscript {
allprojects { allprojects {
repositories { repositories {
google() google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral() mavenCentral()
} }
} }
+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 /// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_'; static const String podcastCover = 'podcast_cover_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
/// Playlist title hero tag prefix
static const String playlistTitle = 'playlist_title_';
/// Radio station cover hero tag prefix
static const String radioCover = 'radio_cover_';
/// Radio station title hero tag prefix
static const String radioTitle = 'radio_title_';
} }
+35
View File
@@ -251,6 +251,16 @@
"description": "Home screen section title" "description": "Home screen section title"
}, },
"favoritePlaylists": "Favorite Playlists",
"@favoritePlaylists": {
"description": "Home screen section title"
},
"favoriteRadioStations": "Favorite Radio Stations",
"@favoriteRadioStations": {
"description": "Home screen section title"
},
"searchMusic": "Search music...", "searchMusic": "Search music...",
"@searchMusic": { "@searchMusic": {
"description": "Search placeholder text" "description": "Search placeholder text"
@@ -1062,10 +1072,15 @@
"showFavoriteAlbums": "Show a row of your favorite albums", "showFavoriteAlbums": "Show a row of your favorite albums",
"showFavoriteArtists": "Show a row of your favorite artists", "showFavoriteArtists": "Show a row of your favorite artists",
"showFavoriteTracks": "Show a row of your favorite tracks", "showFavoriteTracks": "Show a row of your favorite tracks",
"showFavoritePlaylists": "Show a row of your favorite playlists",
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
"extractColorsFromArtwork": "Extract colors from album and artist artwork", "extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen", "chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites", "addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites", "removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library",
"unknown": "Unknown", "unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks", "noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all", "showAll": "Show all",
@@ -1355,5 +1370,25 @@
"dismissPlayerHint": "Swipe down, tap outside, or press back to return", "dismissPlayerHint": "Swipe down, tap outside, or press back to return",
"@dismissPlayerHint": { "@dismissPlayerHint": {
"description": "Hint explaining how to dismiss the player selector" "description": "Hint explaining how to dismiss the player selector"
},
"playingAlbum": "Playing {albumName}",
"@playingAlbum": {
"description": "Message shown when starting to play an album",
"placeholders": {
"albumName": {
"type": "String"
}
}
},
"playingPlaylist": "Playing {playlistName}",
"@playingPlaylist": {
"description": "Message shown when starting to play a playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
} }
} }
+20 -10
View File
@@ -71,13 +71,16 @@ Future<void> main() async {
DeviceOrientation.portraitDown, DeviceOrientation.portraitDown,
]); ]);
// Set system UI overlay style // Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light, statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: Color(0xFF1a1a1a), systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
), ),
); );
@@ -229,10 +232,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
ColorScheme? darkColorScheme; ColorScheme? darkColorScheme;
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) { if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
// Use system color schemes // Use system color schemes, but override background to match app design
// Material You doesn't set background consistently, causing black screen issues
final (light, dark) = snapshot.data!; final (light, dark) = snapshot.data!;
lightColorScheme = light; lightColorScheme = light.copyWith(
darkColorScheme = dark; surface: light.surface,
background: const Color(0xFFFAFAFA), // App's preferred light background
);
darkColorScheme = dark.copyWith(
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
background: const Color(0xFF1a1a1a), // App's preferred dark background
);
} else { } else {
// Use custom color from theme provider // Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor); lightColorScheme = generateLightColorScheme(themeProvider.customColor);
@@ -332,8 +342,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark systemNavigationBarColor: isDark
? widget.darkColorScheme.background ? widget.darkColorScheme.surface
: widget.lightColorScheme.background, : widget.lightColorScheme.surface,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark, systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
), ),
); );
+10 -1
View File
@@ -4,6 +4,7 @@ import '../services/music_assistant_api.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/error_handler.dart'; import '../services/error_handler.dart';
import '../services/cache_service.dart'; import '../services/cache_service.dart';
import '../services/settings_service.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
/// Provider for managing library data (artists, albums, tracks, playlists) /// Provider for managing library data (artists, albums, tracks, playlists)
@@ -138,8 +139,12 @@ class LibraryProvider with ChangeNotifier {
_error = null; _error = null;
notifyListeners(); notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
final results = await Future.wait([ final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems), _api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems), _api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems), _api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]); ]);
@@ -166,10 +171,14 @@ class LibraryProvider with ChangeNotifier {
_error = null; _error = null;
notifyListeners(); notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_artists = await _api!.getArtists( _artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems, limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset, offset: offset,
search: search, search: search,
albumArtistsOnly: showOnlyArtistsWithAlbums,
); );
_isLoading = false; _isLoading = false;
File diff suppressed because it is too large Load Diff
+170 -31
View File
@@ -36,6 +36,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
List<Track> _tracks = []; List<Track> _tracks = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false; bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedTrackIndex; int? _expandedTrackIndex;
@@ -52,6 +53,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.album.favorite ?? false; _isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
_loadTracks(); _loadTracks();
_loadAlbumDescription(); _loadAlbumDescription();
@@ -140,7 +142,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => widget.album.providerMappings!.first, orElse: () => widget.album.providerMappings!.first,
), ),
); );
actualProvider = mapping.providerInstance; // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId; actualItemId = mapping.itemId;
} }
@@ -171,7 +174,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
throw Exception('Could not determine library ID for this album'); throw Exception('Could not determine library ID for this album');
} }
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites( success = await maProvider.removeFromFavorites(
mediaType: 'album', mediaType: 'album',
libraryItemId: libraryItemId, libraryItemId: libraryItemId,
@@ -214,6 +216,104 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
} }
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.album.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.album.provider != 'library') {
actualProvider = widget.album.provider;
actualItemId = widget.album.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: album is library-only');
return;
}
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling album library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async { Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return; if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -260,7 +360,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => track.providerMappings!.first, orElse: () => track.providerMappings!.first,
), ),
); );
actualProvider = mapping.providerInstance; // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId; actualItemId = mapping.itemId;
} }
@@ -731,35 +832,50 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
const SizedBox(height: 60), const SizedBox(height: 60),
GestureDetector( GestureDetector(
onTap: () => _showFullscreenArt(imageUrl), onTap: () => _showFullscreenArt(imageUrl),
child: Hero( // Shadow container (outside Hero for correct clipping)
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix, child: Container(
child: Container( decoration: BoxDecoration(
width: coverSize, borderRadius: BorderRadius.circular(12),
height: coverSize, boxShadow: [
decoration: BoxDecoration( BoxShadow(
color: colorScheme.surfaceVariant, color: Colors.black.withOpacity(0.3),
borderRadius: BorderRadius.circular(16), blurRadius: 20,
boxShadow: [ offset: const Offset(0, 10),
BoxShadow( ),
color: Colors.black.withOpacity(0.3), ],
blurRadius: 20, ),
offset: const Offset(0, 10), child: Hero(
), tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
], // FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
image: imageUrl != null child: ClipRRect(
? DecorationImage( borderRadius: BorderRadius.circular(12),
image: CachedNetworkImageProvider(imageUrl), child: Container(
fit: BoxFit.cover, width: coverSize,
) height: coverSize,
: null, color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
), ),
child: imageUrl == null
? Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
)
: null,
), ),
), ),
), ),
@@ -910,6 +1026,29 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
), ),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
+185 -2
View File
@@ -36,11 +36,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _providerAlbums = []; List<Album> _providerAlbums = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false; bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
String? _artistDescription; String? _artistDescription;
String? _artistImageUrl; String? _artistImageUrl;
MusicAssistantProvider? _maProvider;
// View preferences // View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year' String _sortOrder = 'alpha'; // 'alpha' or 'year'
@@ -52,6 +54,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.artist.favorite ?? false; _isFavorite = widget.artist.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.artist);
// Use initial image URL immediately for smooth hero animation // Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl; _artistImageUrl = widget.initialImageUrl;
_loadViewPreferences(); _loadViewPreferences();
@@ -66,11 +69,60 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
_loadArtistImage(); _loadArtistImage();
// Note: _extractColors is called by _loadArtistImage after image loads // Note: _extractColors is called by _loadArtistImage after image loads
// CRITICAL FIX: Delay adding provider listener until AFTER Hero animation
// Adding it immediately causes rebuilds during animation (jank)
_maProvider = context.read<MusicAssistantProvider>();
_maProvider?.addListener(_onProviderChanged);
} }
}); });
}); });
} }
void _onProviderChanged() {
if (!mounted) return;
// Re-check library status when provider data changes
final newIsInLibrary = _checkIfInLibraryFromProvider();
if (newIsInLibrary != _isInLibrary) {
setState(() {
_isInLibrary = newIsInLibrary;
});
}
}
/// Check if artist is in library using provider's artists list
bool _checkIfInLibraryFromProvider() {
if (_maProvider == null) return _checkIfInLibrary(widget.artist);
final artistName = widget.artist.name.toLowerCase();
final artistUri = widget.artist.uri;
// Check if this artist exists in the provider's library
return _maProvider!.artists.any((a) {
// Match by URI if available
if (artistUri != null && a.uri == artistUri) return true;
// Match by name as fallback
if (a.name.toLowerCase() == artistName) return true;
// Check provider mappings for matching URIs
if (widget.artist.providerMappings != null) {
for (final mapping in widget.artist.providerMappings!) {
if (a.providerMappings?.any((m) =>
m.providerInstance == mapping.providerInstance &&
m.itemId == mapping.itemId) == true) {
return true;
}
}
}
return false;
});
}
@override
void dispose() {
_maProvider?.removeListener(_onProviderChanged);
super.dispose();
}
Future<void> _loadViewPreferences() async { Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getArtistAlbumsSortOrder(); final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
final viewMode = await SettingsService.getArtistAlbumsViewMode(); final viewMode = await SettingsService.getArtistAlbumsViewMode();
@@ -172,7 +224,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
orElse: () => widget.artist.providerMappings!.first, orElse: () => widget.artist.providerMappings!.first,
), ),
); );
actualProvider = mapping.providerInstance; // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId; actualItemId = mapping.itemId;
} }
@@ -202,7 +255,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
throw Exception('Could not determine library ID for this artist'); throw Exception('Could not determine library ID for this artist');
} }
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites( success = await maProvider.removeFromFavorites(
mediaType: 'artist', mediaType: 'artist',
libraryItemId: libraryItemId, libraryItemId: libraryItemId,
@@ -243,6 +295,110 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
} }
} }
/// Check if artist is in library
bool _checkIfInLibrary(Artist artist) {
if (artist.provider == 'library') return true;
return artist.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.artist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.artist.provider != 'library') {
actualProvider = widget.artist.provider;
actualItemId = widget.artist.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: artist is library-only');
return;
}
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling artist library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
void _showRadioMenu(BuildContext context) { void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers; final players = maProvider.availablePlayers;
@@ -587,8 +743,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon( errorWidget: (_, __, ___) => Icon(
Icons.person_rounded, Icons.person_rounded,
size: coverSize * 0.5, size: coverSize * 0.5,
@@ -712,6 +872,29 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
), ),
), ),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
+5 -2
View File
@@ -140,7 +140,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
orElse: () => widget.audiobook.providerMappings!.first, orElse: () => widget.audiobook.providerMappings!.first,
), ),
); );
actualProvider = mapping.providerInstance; // Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId; actualItemId = mapping.itemId;
} }
@@ -169,7 +170,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
throw Exception('Could not determine library ID for this audiobook'); throw Exception('Could not determine library ID for this audiobook');
} }
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites( success = await maProvider.removeFromFavorites(
mediaType: 'audiobook', mediaType: 'audiobook',
libraryItemId: libraryItemId, libraryItemId: libraryItemId,
@@ -512,6 +512,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center( placeholder: (_, __) => Center(
+6
View File
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
return; return;
} }
// If queue panel is open, close it first (before collapsing player)
if (GlobalPlayerOverlay.isQueuePanelOpen) {
GlobalPlayerOverlay.closeQueuePanel();
return;
}
// If global player is expanded, collapse it first // If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) { if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer(); GlobalPlayerOverlay.collapsePlayer();
+1 -1
View File
@@ -64,7 +64,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () async { onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary(); await context.read<MusicAssistantProvider>().loadLibrary();
}, },
+1 -1
View File
@@ -66,7 +66,7 @@ class LibraryArtistsScreen extends StatelessWidget {
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () async { onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary(); await context.read<MusicAssistantProvider>().loadLibrary();
}, },
+6 -12
View File
@@ -30,17 +30,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
}); });
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) { final playlists = await maProvider.getPlaylists(limit: 100);
final playlists = await maProvider.api!.getPlaylists(limit: 100); setState(() {
setState(() { _playlists = playlists;
_playlists = playlists; _isLoading = false;
_isLoading = false; });
});
} else {
setState(() {
_isLoading = false;
});
}
} }
@override @override
@@ -89,7 +83,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: _loadPlaylists, onRefresh: _loadPlaylists,
child: ListView.builder( child: ListView.builder(
itemCount: _playlists.length, itemCount: _playlists.length,
+27 -1
View File
@@ -13,6 +13,8 @@ import '../widgets/artist_row.dart';
import '../widgets/track_row.dart'; import '../widgets/track_row.dart';
import '../widgets/audiobook_row.dart'; import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart'; import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
@@ -35,6 +37,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false; bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false; bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false; bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
// Audiobook rows (default off) // Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false; bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false; bool _showDiscoverAudiobooks = false;
@@ -73,6 +77,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showFavAlbums = await SettingsService.getShowFavoriteAlbums(); final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists(); final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks(); final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks(); final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks(); final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries(); final showDiscSeries = await SettingsService.getShowDiscoverSeries();
@@ -85,6 +91,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums; _showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists; _showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showContinueListeningAudiobooks = showContAudiobooks; _showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
@@ -194,7 +202,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
RefreshIndicator( RefreshIndicator(
onRefresh: _onRefresh, onRefresh: _onRefresh,
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
child: _buildConnectedView(context, maProvider), child: _buildConnectedView(context, maProvider),
), ),
// Connecting banner overlay (doesn't affect layout) // Connecting banner overlay (doesn't affect layout)
@@ -373,6 +381,24 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
loadTracks: () => provider.getFavoriteTracks(), loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight, rowHeight: rowHeight,
); );
case 'favorite-playlists':
if (!_showFavoritePlaylists) return null;
return PlaylistRow(
key: const ValueKey('favorite-playlists'),
title: S.of(context)!.favoritePlaylists,
loadPlaylists: () => provider.getFavoritePlaylists(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-radio-stations':
if (!_showFavoriteRadioStations) return null;
return RadioStationRow(
key: const ValueKey('favorite-radio-stations'),
title: S.of(context)!.favoriteRadioStations,
loadRadioStations: () => provider.getFavoriteRadioStations(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
default: default:
return null; return null;
} }
+176 -152
View File
@@ -114,6 +114,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final ScrollController _authorsScrollController = ScrollController(); final ScrollController _authorsScrollController = ScrollController();
final ScrollController _audiobooksScrollController = ScrollController(); final ScrollController _audiobooksScrollController = ScrollController();
final ScrollController _seriesScrollController = ScrollController(); final ScrollController _seriesScrollController = ScrollController();
final ScrollController _podcastsScrollController = ScrollController();
final ScrollController _radioScrollController = ScrollController();
int get _tabCount { int get _tabCount {
switch (_selectedMediaType) { switch (_selectedMediaType) {
@@ -585,33 +587,27 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_authorsScrollController.dispose(); _authorsScrollController.dispose();
_audiobooksScrollController.dispose(); _audiobooksScrollController.dispose();
_seriesScrollController.dispose(); _seriesScrollController.dispose();
_podcastsScrollController.dispose();
_radioScrollController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _loadPlaylists({bool? favoriteOnly}) async { Future<void> _loadPlaylists({bool? favoriteOnly}) async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) { final playlists = await maProvider.getPlaylists(
final playlists = await maProvider.api!.getPlaylists( limit: 100,
limit: 100, favoriteOnly: favoriteOnly,
favoriteOnly: favoriteOnly, );
); if (mounted) {
if (mounted) { // PERF: Pre-sort once on load, not on every build
// PERF: Pre-sort once on load, not on every build final sorted = List<Playlist>.from(playlists)
final sorted = List<Playlist>.from(playlists) ..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? '')); setState(() {
setState(() { _playlists = playlists;
_playlists = playlists; _sortedPlaylists = sorted;
_sortedPlaylists = sorted; _playlistNames = sorted.map((p) => p.name ?? '').toList();
_playlistNames = sorted.map((p) => p.name ?? '').toList(); _isLoadingPlaylists = false;
_isLoadingPlaylists = false; });
});
}
} else {
if (mounted) {
setState(() {
_isLoadingPlaylists = false;
});
}
} }
} }
@@ -665,48 +661,39 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}); });
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) { _logger.log('📚 Calling getAudiobooks...');
_logger.log('📚 Calling API getAudiobooks...'); final audiobooks = await maProvider.getAudiobooks(
final audiobooks = await maProvider.api!.getAudiobooks( limit: 10000, // Large limit to get all audiobooks
limit: 10000, // Large limit to get all audiobooks favoriteOnly: favoriteOnly,
favoriteOnly: favoriteOnly, );
); _logger.log('📚 Returned ${audiobooks.length} audiobooks');
_logger.log('📚 API returned ${audiobooks.length} audiobooks'); if (audiobooks.isNotEmpty) {
if (audiobooks.isNotEmpty) { _logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}');
_logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}'); }
} if (mounted) {
if (mounted) { // PERF: Pre-sort and pre-group once on load, not on every build
// PERF: Pre-sort and pre-group once on load, not on every build final sortedAlpha = List<Audiobook>.from(audiobooks)
final sortedAlpha = List<Audiobook>.from(audiobooks) ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// Group audiobooks by author // Group audiobooks by author
final authorMap = <String, List<Audiobook>>{}; final authorMap = <String, List<Audiobook>>{};
for (final book in audiobooks) { for (final book in audiobooks) {
final authorName = book.authorsString; final authorName = book.authorsString;
authorMap.putIfAbsent(authorName, () => []).add(book); authorMap.putIfAbsent(authorName, () => []).add(book);
} }
final sortedAuthors = authorMap.keys.toList()..sort(); final sortedAuthors = authorMap.keys.toList()..sort();
setState(() { setState(() {
_audiobooks = audiobooks; _audiobooks = audiobooks;
_sortedAudiobooks = sortedAlpha; _sortedAudiobooks = sortedAlpha;
_audiobookNames = sortedAlpha.map((a) => a.name).toList(); _audiobookNames = sortedAlpha.map((a) => a.name).toList();
_groupedAudiobooksByAuthor = authorMap; _groupedAudiobooksByAuthor = authorMap;
_sortedAuthorNames = sortedAuthors; _sortedAuthorNames = sortedAuthors;
_isLoadingAudiobooks = false; _isLoadingAudiobooks = false;
}); });
_logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}'); _logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}');
// Fetch author images in background // Fetch author images in background
_fetchAuthorImages(audiobooks); _fetchAuthorImages(audiobooks);
}
} else {
_logger.log('📚 API is null!');
if (mounted) {
setState(() {
_isLoadingAudiobooks = false;
});
}
} }
} }
@@ -1014,7 +1001,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
colorScheme.background, colorScheme.background,
colorScheme.background.withOpacity(0), colorScheme.surface.withOpacity(0),
], ],
), ),
), ),
@@ -1156,7 +1143,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
width: 36, width: 36,
height: 36, height: 36,
child: Material( child: Material(
color: _showFavoritesOnly ? Colors.red : colorScheme.surface, color: _showFavoritesOnly ? Colors.red : colorScheme.background,
elevation: 2, elevation: 2,
shadowColor: Colors.black26, shadowColor: Colors.black26,
shape: const CircleBorder(), shape: const CircleBorder(),
@@ -1177,7 +1164,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
width: 36, width: 36,
height: 36, height: 36,
child: Material( child: Material(
color: colorScheme.surface, color: colorScheme.background,
elevation: 2, elevation: 2,
shadowColor: Colors.black26, shadowColor: Colors.black26,
shape: const CircleBorder(), shape: const CircleBorder(),
@@ -1330,7 +1317,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Match music artists tab layout - no header, direct list/grid // Match music artists tab layout - no header, direct list/grid
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null), onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar( child: LetterScrollbar(
controller: _authorsScrollController, controller: _authorsScrollController,
@@ -1393,6 +1380,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover, fit: BoxFit.cover,
width: 48, width: 48,
height: 48, height: 48,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => Text( placeholder: (_, __) => Text(
@@ -1478,6 +1467,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover, fit: BoxFit.cover,
width: size, width: size,
height: size, height: size,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center( placeholder: (_, __) => Center(
@@ -1568,6 +1559,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => const SizedBox(), placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon( errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline, MdiIcons.bookOutline,
@@ -1667,7 +1660,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Match music albums tab layout - no header, direct list/grid // Match music albums tab layout - no header, direct list/grid
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null), onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar( child: LetterScrollbar(
controller: _audiobooksScrollController, controller: _audiobooksScrollController,
@@ -1736,6 +1729,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 512,
memCacheHeight: 512,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(), placeholder: (_, __) => const SizedBox(),
@@ -1955,6 +1950,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: firstCover, imageUrl: firstCover,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => Icon( placeholder: (_, __) => Icon(
Icons.collections_bookmark_rounded, Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -2269,12 +2266,20 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Use consistent 256 for all views to improve hero animation smoothness (matches detail screen) // Use consistent 256 for all views to improve hero animation smoothness (matches detail screen)
const cacheSize = 256; const cacheSize = 256;
// Generate podcast names for letter scrollbar
final podcastNames = podcasts.map((p) => p.name).toList();
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () => maProvider.loadPodcasts(), onRefresh: () => maProvider.loadPodcasts(),
child: _podcastsViewMode == 'list' child: LetterScrollbar(
controller: _podcastsScrollController,
items: podcastNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _podcastsViewMode == 'list'
? ListView.builder( ? ListView.builder(
controller: _podcastsScrollController,
key: const PageStorageKey<String>('podcasts_list'), key: const PageStorageKey<String>('podcasts_list'),
cacheExtent: 1000, cacheExtent: 1000,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer), padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2301,7 +2306,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
width: 56, width: 56,
height: 56, height: 56,
fit: BoxFit.cover, fit: BoxFit.cover,
// No memCacheWidth - decode at native resolution for smooth hero // FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(), placeholder: (_, __) => const SizedBox(),
@@ -2340,6 +2347,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}, },
) )
: GridView.builder( : GridView.builder(
controller: _podcastsScrollController,
key: PageStorageKey<String>('podcasts_grid_$_podcastsViewMode'), key: PageStorageKey<String>('podcasts_grid_$_podcastsViewMode'),
cacheExtent: 1000, cacheExtent: 1000,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2355,6 +2363,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return _buildPodcastCard(podcast, maProvider, cacheSize); return _buildPodcastCard(podcast, maProvider, cacheSize);
}, },
), ),
),
); );
} }
@@ -2382,7 +2391,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
// No memCacheWidth - decode at native resolution for smooth hero // FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(), placeholder: (_, __) => const SizedBox(),
@@ -2484,12 +2495,20 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Use appropriate cache size based on view mode // PERF: Use appropriate cache size based on view mode
final cacheSize = _radioViewMode == 'grid3' ? 200 : 256; final cacheSize = _radioViewMode == 'grid3' ? 200 : 256;
// Generate radio station names for letter scrollbar
final radioNames = radioStations.map((s) => s.name).toList();
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () => maProvider.loadRadioStations(), onRefresh: () => maProvider.loadRadioStations(),
child: _radioViewMode == 'list' child: LetterScrollbar(
controller: _radioScrollController,
items: radioNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _radioViewMode == 'list'
? ListView.builder( ? ListView.builder(
controller: _radioScrollController,
key: const PageStorageKey<String>('radio_stations_list'), key: const PageStorageKey<String>('radio_stations_list'),
cacheExtent: 1000, cacheExtent: 1000,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer), padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2555,6 +2574,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}, },
) )
: GridView.builder( : GridView.builder(
controller: _radioScrollController,
key: PageStorageKey<String>('radio_stations_grid_$_radioViewMode'), key: PageStorageKey<String>('radio_stations_grid_$_radioViewMode'),
cacheExtent: 1000, cacheExtent: 1000,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer), padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2570,6 +2590,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return _buildRadioCard(station, maProvider, cacheSize); return _buildRadioCard(station, maProvider, cacheSize);
}, },
), ),
),
); );
} }
@@ -2642,89 +2663,90 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ARTISTS TAB ============ // ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context, S l10n) { Widget _buildArtistsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes // Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
// Artist filtering (albumArtistsOnly) is now done at API level in library_provider
return Selector<MusicAssistantProvider, (List<Artist>, bool)>( return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading), selector: (_, provider) => (provider.artists, provider.isLoading),
builder: (context, data, _) { builder: (context, data, _) {
final (allArtists, isLoading) = data; final (allArtists, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (isLoading) { if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary)); return Center(child: CircularProgressIndicator(color: colorScheme.primary));
} }
// Filter by favorites if enabled // Filter by favorites if enabled (artist filter is done at API level)
final artists = _showFavoritesOnly final artists = _showFavoritesOnly
? allArtists.where((a) => a.favorite == true).toList() ? allArtists.where((a) => a.favorite == true).toList()
: allArtists; : allArtists;
if (artists.isEmpty) { if (artists.isEmpty) {
if (_showFavoritesOnly) { if (_showFavoritesOnly) {
return EmptyState.custom( return EmptyState.custom(
context: context, context: context,
icon: Icons.favorite_border, icon: Icons.favorite_border,
title: l10n.noFavoriteArtists, title: l10n.noFavoriteArtists,
subtitle: l10n.tapHeartArtist, subtitle: l10n.tapHeartArtist,
);
}
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// Sort artists alphabetically for letter scrollbar
final sortedArtists = List<Artist>.from(artists)
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _artistsScrollController,
items: artistNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _artistsViewMode == 'list'
? ListView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedArtists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistTile(
context,
artist,
key: ValueKey(artist.uri ?? artist.itemId),
);
},
)
: GridView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedArtists.length,
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistGridCard(context, artist);
},
),
),
); );
} },
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// Sort artists alphabetically for letter scrollbar
final sortedArtists = List<Artist>.from(artists)
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _artistsScrollController,
items: artistNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _artistsViewMode == 'list'
? ListView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedArtists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistTile(
context,
artist,
key: ValueKey(artist.uri ?? artist.itemId),
);
},
)
: GridView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedArtists.length,
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistGridCard(context, artist);
},
),
),
);
},
); );
} }
@@ -2879,7 +2901,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(), onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar( child: LetterScrollbar(
controller: _albumsScrollController, controller: _albumsScrollController,
@@ -2949,6 +2971,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => const SizedBox(), placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon( errorWidget: (_, __, ___) => Icon(
Icons.album_rounded, Icons.album_rounded,
@@ -3016,7 +3040,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Use pre-sorted lists (sorted once on load) // PERF: Use pre-sorted lists (sorted once on load)
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null), onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar( child: LetterScrollbar(
controller: _playlistsScrollController, controller: _playlistsScrollController,
@@ -3215,7 +3239,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Use pre-sorted list (sorted once on load) // PERF: Use pre-sorted list (sorted once on load)
return RefreshIndicator( return RefreshIndicator(
color: colorScheme.primary, color: colorScheme.primary,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.background,
onRefresh: _loadFavoriteTracks, onRefresh: _loadFavoriteTracks,
child: ListView.builder( child: ListView.builder(
key: const PageStorageKey<String>('library_tracks_list'), key: const PageStorageKey<String>('library_tracks_list'),
+190 -6
View File
@@ -35,6 +35,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
List<MediaItem> _episodes = []; List<MediaItem> _episodes = [];
bool _isLoadingEpisodes = false; bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions String? _expandedEpisodeId; // Track which episode is expanded for actions
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -42,6 +43,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
@@ -52,6 +54,110 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}); });
} }
/// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.podcast.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.podcast.provider != 'library') {
actualProvider = widget.podcast.provider;
actualItemId = widget.podcast.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: podcast is library-only');
return;
}
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling podcast library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _extractColors() async { Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation // Use initialImageUrl first for consistency with hero animation
@@ -249,11 +355,14 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); // CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
// CRITICAL: Use initialImageUrl for hero animation consistency // watch() rebuilds on ANY provider change (library updates, etc.) causing animation jank
// The library passes the same URL it uses for display, ensuring cache hit // select() only rebuilds when the specific podcast image URL changes
// Only fall back to provider URL if initialImageUrl is null final providerImageUrl = context.select<MusicAssistantProvider, String?>(
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast); (provider) => provider.getPodcastImageUrl(widget.podcast),
);
// Use initialImageUrl for seamless hero animation (same URL as source)
final imageUrl = widget.initialImageUrl ?? providerImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>( final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme, (provider) => provider.adaptiveTheme,
@@ -325,6 +434,9 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center( placeholder: (_, __) => Center(
@@ -383,6 +495,78 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
// Action buttons row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _playEpisode(_episodes.first),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
disabledForegroundColor: colorScheme.onPrimary.withOpacity(0.38),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
// Description // Description
if (description != null && description.isNotEmpty) ...[ if (description != null && description.isNotEmpty) ...[
GestureDetector( GestureDetector(
@@ -483,7 +667,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final duration = episode.duration; final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId; final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId; final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = maProvider.getImageUrl(episode, size: 256); final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata // Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata); final releaseDate = _formatReleaseDate(episode.metadata);
File diff suppressed because it is too large Load Diff
+117 -5
View File
@@ -29,6 +29,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteAlbums = false; bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false; bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false; bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
// Audiobook home rows (default off) // Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false; bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false; bool _showDiscoverAudiobooks = false;
@@ -41,8 +43,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Player settings // Player settings
bool _preferLocalPlayer = false; bool _preferLocalPlayer = false;
bool _smartSortPlayers = false; bool _smartSortPlayers = false;
bool _volumePrecisionMode = true;
// Hint settings // Hint settings
bool _showHints = true; bool _showHints = true;
// Library settings
bool _showOnlyArtistsWithAlbums = false;
@override @override
void initState() { void initState() {
@@ -68,6 +73,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showFavAlbums = await SettingsService.getShowFavoriteAlbums(); final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists(); final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks(); final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
// Load audiobook home row settings // Load audiobook home row settings
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks(); final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
@@ -90,10 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load player settings // Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer(); final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers(); final smartSort = await SettingsService.getSmartSortPlayers();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings // Load hint settings
final showHints = await SettingsService.getShowHints(); final showHints = await SettingsService.getShowHints();
// Load library settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
if (mounted) { if (mounted) {
setState(() { setState(() {
_showRecentAlbums = showRecent; _showRecentAlbums = showRecent;
@@ -102,6 +113,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums; _showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists; _showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks; _showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showContinueListeningAudiobooks = showContinueAudiobooks; _showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
@@ -110,7 +123,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
_libraryEnabled = libraryEnabled; _libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal; _preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort; _smartSortPlayers = smartSort;
_volumePrecisionMode = volumePrecision;
_showHints = showHints; _showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
}); });
} }
} }
@@ -144,6 +159,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists}; return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks': case 'favorite-tracks':
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks}; return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
case 'favorite-playlists':
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
case 'favorite-radio-stations':
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
default: default:
return {'title': rowId, 'subtitle': ''}; return {'title': rowId, 'subtitle': ''};
} }
@@ -170,6 +189,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoriteArtists; return _showFavoriteArtists;
case 'favorite-tracks': case 'favorite-tracks':
return _showFavoriteTracks; return _showFavoriteTracks;
case 'favorite-playlists':
return _showFavoritePlaylists;
case 'favorite-radio-stations':
return _showFavoriteRadioStations;
default: default:
return false; return false;
} }
@@ -215,6 +238,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteTracks = value; _showFavoriteTracks = value;
SettingsService.setShowFavoriteTracks(value); SettingsService.setShowFavoriteTracks(value);
break; break;
case 'favorite-playlists':
_showFavoritePlaylists = value;
SettingsService.setShowFavoritePlaylists(value);
break;
case 'favorite-radio-stations':
_showFavoriteRadioStations = value;
SettingsService.setShowFavoriteRadioStations(value);
break;
} }
}); });
} }
@@ -281,13 +312,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Logo - same size as login screen (50% of screen width) // Logo - same size as login screen (50% of screen width)
// Use color filter to make logo dark in light theme
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0), padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
child: Image.asset( child: Theme.of(context).brightness == Brightness.light
'assets/images/ensemble_icon_transparent.png', ? ColorFiltered(
width: MediaQuery.of(context).size.width * 0.5, colorFilter: const ColorFilter.mode(
fit: BoxFit.contain, Color(0xFF1a1a1a), // Dark color for light theme
), BlendMode.srcIn,
),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
)
: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
), ),
// Connection status box - centered with border radius like theme boxes // Connection status box - centered with border radius like theme boxes
@@ -702,6 +746,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
), ),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
onChanged: (value) {
setState(() => _volumePrecisionMode = value);
SettingsService.setVolumePrecisionMode(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -745,6 +814,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// Library section
Text(
S.of(context)!.library,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Show only artists with albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hide artists that have no albums in your library',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
),
value: _showOnlyArtistsWithAlbums,
onChanged: (value) async {
setState(() => _showOnlyArtistsWithAlbums = value);
await SettingsService.setShowOnlyArtistsWithAlbums(value);
// Force sync library to apply the new filter at API level
if (mounted) {
context.read<MusicAssistantProvider>().forceLibrarySync();
}
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section // Home Screen section
Text( Text(
S.of(context)!.homeScreen, S.of(context)!.homeScreen,
+2 -1
View File
@@ -100,7 +100,8 @@ class AuthManager {
host.startsWith('172.31.') || host.startsWith('172.31.') ||
host == 'localhost' || host == 'localhost' ||
host.startsWith('127.') || host.startsWith('127.') ||
host.endsWith('.local'); host.endsWith('.local') ||
host.endsWith('.ts.net');
} }
/// Try to detect auth strategy for a given URL /// Try to detect auth strategy for a given URL
+46 -2
View File
@@ -120,6 +120,22 @@ class CacheService {
_logger.log('🗑️ Home screen cache invalidated'); _logger.log('🗑️ Home screen cache invalidated');
} }
/// Invalidate home album caches (call when albums are added/removed from library)
void invalidateHomeAlbumCaches() {
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_logger.log('🗑️ Home album caches invalidated');
}
/// Invalidate home artist caches (call when artists are added/removed from library)
void invalidateHomeArtistCaches() {
_cachedDiscoverArtists = null;
_discoverArtistsLastFetched = null;
_logger.log('🗑️ Home artist caches invalidated');
}
// ============================================================================ // ============================================================================
// DETAIL SCREEN CACHING // DETAIL SCREEN CACHING
// ============================================================================ // ============================================================================
@@ -145,12 +161,19 @@ class CacheService {
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey'); _logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
} }
/// Invalidate album tracks cache /// Invalidate album tracks cache for a specific album
void invalidateAlbumTracksCache(String albumId) { void invalidateAlbumTracksCache(String albumId) {
_albumTracksCache.remove(albumId); _albumTracksCache.remove(albumId);
_albumTracksCacheTime.remove(albumId); _albumTracksCacheTime.remove(albumId);
} }
/// Invalidate all album tracks caches (call when tracks are added/removed from library)
void invalidateAllAlbumTracksCaches() {
_albumTracksCache.clear();
_albumTracksCacheTime.clear();
_logger.log('🗑️ All album tracks caches invalidated');
}
/// Check if playlist tracks cache is valid /// Check if playlist tracks cache is valid
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) { bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false; if (forceRefresh) return false;
@@ -172,12 +195,19 @@ class CacheService {
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey'); _logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
} }
/// Invalidate playlist tracks cache /// Invalidate playlist tracks cache for a specific playlist
void invalidatePlaylistTracksCache(String playlistId) { void invalidatePlaylistTracksCache(String playlistId) {
_playlistTracksCache.remove(playlistId); _playlistTracksCache.remove(playlistId);
_playlistTracksCacheTime.remove(playlistId); _playlistTracksCacheTime.remove(playlistId);
} }
/// Invalidate all playlist tracks caches (call when tracks are added/removed from library)
void invalidateAllPlaylistTracksCaches() {
_playlistTracksCache.clear();
_playlistTracksCacheTime.clear();
_logger.log('🗑️ All playlist tracks caches invalidated');
}
/// Check if artist albums cache is valid /// Check if artist albums cache is valid
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) { bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false; if (forceRefresh) return false;
@@ -199,6 +229,13 @@ class CacheService {
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey'); _logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
} }
/// Invalidate all artist albums caches (call when albums are added/removed from library)
void invalidateArtistAlbumsCache() {
_artistAlbumsCache.clear();
_artistAlbumsCacheTime.clear();
_logger.log('🗑️ Artist albums cache invalidated');
}
// ============================================================================ // ============================================================================
// SEARCH CACHING // SEARCH CACHING
// ============================================================================ // ============================================================================
@@ -224,6 +261,13 @@ class CacheService {
_logger.log('✅ Cached search results for "$cacheKey"'); _logger.log('✅ Cached search results for "$cacheKey"');
} }
/// Invalidate all search cache (call when library items change)
void invalidateSearchCache() {
_searchCache.clear();
_searchCacheTime.clear();
_logger.log('🗑️ Search cache invalidated');
}
// ============================================================================ // ============================================================================
// PLAYER CACHING // PLAYER CACHING
// ============================================================================ // ============================================================================
+10
View File
@@ -157,6 +157,16 @@ class DatabaseService {
/// Clear all cached data (useful for logout) /// Clear all cached data (useful for logout)
Future<void> clearAllCache() => db.clearAllCache(); Future<void> clearAllCache() => db.clearAllCache();
/// Clear cached items of a specific type (e.g., 'album', 'artist')
/// Used before re-syncing to remove stale items
Future<void> clearCacheForType(String itemType) => db.clearCache(itemType);
/// Mark a specific cached item as deleted
/// Used when removing items from library for immediate database update
Future<void> markCachedItemDeleted(String itemType, String itemId) {
return db.markItemsDeleted(itemType, [itemId]);
}
// ============================================ // ============================================
// Player Cache Convenience Methods // Player Cache Convenience Methods
// ============================================ // ============================================
+177 -101
View File
@@ -293,16 +293,10 @@ class MusicAssistantAPI {
// Handle event // Handle event
final eventType = data['event'] as String?; final eventType = data['event'] as String?;
if (eventType != null) { if (eventType != null) {
_logger.log('Event received: $eventType');
// Get the object_id (player_id for player events) // Get the object_id (player_id for player events)
final objectId = data['object_id'] as String?; final objectId = data['object_id'] as String?;
final eventData = data['data'] as Map<String, dynamic>? ?? {}; final eventData = data['data'] as Map<String, dynamic>? ?? {};
// Debug: Log full data for player events
if (eventType == 'player_added' || eventType == 'player_updated' || eventType == 'builtin_player') {
_logger.log('Event data: ${jsonEncode(eventData)} (player_id: $objectId)');
}
// Include object_id in event data so listeners can filter by player // Include object_id in event data so listeners can filter by player
final enrichedData = { final enrichedData = {
@@ -528,7 +522,6 @@ class MusicAssistantAPI {
/// Search for radio stations globally (across all providers like TuneIn) /// Search for radio stations globally (across all providers like TuneIn)
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async { Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
try { try {
_logger.log('📻 Searching radio stations for: $query');
final response = await _sendCommand( final response = await _sendCommand(
'music/search', 'music/search',
args: { args: {
@@ -539,23 +532,18 @@ class MusicAssistantAPI {
); );
final result = response['result'] as Map<String, dynamic>?; final result = response['result'] as Map<String, dynamic>?;
if (result == null) { if (result == null) return [];
_logger.log('📻 Radio search: no result');
return [];
}
// Radio results might be under 'radios' or 'radio' key // Radio results might be under 'radios' or 'radio' key
final radios = (result['radios'] as List<dynamic>?) ?? final radios = (result['radios'] as List<dynamic>?) ??
(result['radio'] as List<dynamic>?) ?? (result['radio'] as List<dynamic>?) ??
[]; [];
_logger.log('📻 Radio search found ${radios.length} results');
return radios return radios
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>)) .map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList(); .toList();
} catch (e) { } catch (e) {
_logger.log('📻 Error searching radio stations: $e'); _logger.log('Error searching radio stations: $e');
return []; return [];
} }
} }
@@ -569,7 +557,6 @@ class MusicAssistantAPI {
bool? favoriteOnly, bool? favoriteOnly,
}) async { }) async {
try { try {
_logger.log('🎙️ Getting podcasts...');
final response = await _sendCommand( final response = await _sendCommand(
'music/podcasts/library_items', 'music/podcasts/library_items',
args: { args: {
@@ -580,18 +567,13 @@ class MusicAssistantAPI {
); );
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?; final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) { if (items == null) return [];
_logger.log('🎙️ Podcasts: result is null');
return [];
}
_logger.log('🎙️ Found ${items.length} podcasts');
return items return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>)) .map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList(); .toList();
} catch (e) { } catch (e) {
_logger.log('🎙️ Error getting podcasts: $e'); _logger.log('Error getting podcasts: $e');
return []; return [];
} }
} }
@@ -603,9 +585,6 @@ class MusicAssistantAPI {
int? offset, int? offset,
}) async { }) async {
try { try {
_logger.log('🎙️ Getting episodes for podcast: $podcastId, provider: $provider');
// Use the correct endpoint: music/podcasts/podcast_episodes
final response = await _sendCommand( final response = await _sendCommand(
'music/podcasts/podcast_episodes', 'music/podcasts/podcast_episodes',
args: { args: {
@@ -614,21 +593,16 @@ class MusicAssistantAPI {
}, },
); );
_logger.log('🎙️ Response keys: ${response.keys.toList()}');
// Response should be a list of episodes
final episodes = response['result'] as List<dynamic>?; final episodes = response['result'] as List<dynamic>?;
if (episodes != null && episodes.isNotEmpty) { if (episodes != null && episodes.isNotEmpty) {
_logger.log('🎙️ Found ${episodes.length} episodes');
return episodes return episodes
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>)) .map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList(); .toList();
} }
_logger.log('🎙️ No episodes found for podcast');
return []; return [];
} catch (e) { } catch (e) {
_logger.log('🎙️ Error getting podcast episodes: $e'); _logger.log('Error getting podcast episodes: $e');
return []; return [];
} }
} }
@@ -648,7 +622,6 @@ class MusicAssistantAPI {
// No podcast image, try to get first episode's image // No podcast image, try to get first episode's image
try { try {
_logger.log('🎙️ No podcast cover, fetching episode cover for: ${podcast.name}');
final episodes = await getPodcastEpisodes( final episodes = await getPodcastEpisodes(
podcast.itemId, podcast.itemId,
provider: podcast.provider, provider: podcast.provider,
@@ -658,12 +631,11 @@ class MusicAssistantAPI {
final firstEpisode = episodes.first; final firstEpisode = episodes.first;
final episodeImage = getImageUrl(firstEpisode, size: size); final episodeImage = getImageUrl(firstEpisode, size: size);
if (episodeImage != null) { if (episodeImage != null) {
_logger.log('🎙️ Using episode cover for podcast: ${podcast.name}');
return episodeImage; return episodeImage;
} }
} }
} catch (e) { } catch (e) {
_logger.log('🎙️ Error fetching episode cover: $e'); // Fall through to return podcast image
} }
// Fall back to podcast image even if it might be low quality // Fall back to podcast image even if it might be low quality
@@ -727,10 +699,6 @@ class MusicAssistantAPI {
uri = 'library://podcast_episode/${episode.itemId}'; uri = 'library://podcast_episode/${episode.itemId}';
} }
_logger.log('🎙️ Playing podcast episode: ${episode.name}');
_logger.log('🎙️ Episode URI: $uri');
_logger.log('🎙️ Episode itemId: ${episode.itemId}');
await _sendCommand( await _sendCommand(
'player_queues/play_media', 'player_queues/play_media',
args: { args: {
@@ -740,7 +708,7 @@ class MusicAssistantAPI {
}, },
); );
} catch (e) { } catch (e) {
_logger.log('🎙️ Error playing podcast episode: $e'); _logger.log('Error playing podcast episode: $e');
rethrow; rethrow;
} }
} }
@@ -758,7 +726,6 @@ class MusicAssistantAPI {
// If specific libraries are enabled, use browse API for filtering // If specific libraries are enabled, use browse API for filtering
if (enabledLibraries != null && enabledLibraries.isNotEmpty) { if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
_logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)');
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly); return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
} }
@@ -771,8 +738,6 @@ class MusicAssistantAPI {
if (authorId != null) 'author_id': authorId, if (authorId != null) 'author_id': authorId,
}; };
_logger.log('📚 Calling music/audiobooks/library_items with args: $args');
final response = await _sendCommand( final response = await _sendCommand(
'music/audiobooks/library_items', 'music/audiobooks/library_items',
args: args, args: args,
@@ -780,65 +745,21 @@ class MusicAssistantAPI {
// Check for error in response // Check for error in response
if (response.containsKey('error_code')) { if (response.containsKey('error_code')) {
_logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}');
return []; return [];
} }
final items = response['result'] as List<dynamic>?; final items = response['result'] as List<dynamic>?;
if (items == null) { if (items == null) return [];
_logger.log('📚 Audiobooks: result is null');
return [];
}
_logger.log('📚 Audiobooks: found ${items.length} items from API');
// Log first item's structure including metadata to find chapters/series
if (items.isNotEmpty) {
final firstItem = items.first as Map<String, dynamic>;
_logger.log('📚 First item keys: ${firstItem.keys.toList()}');
_logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}');
// Log metadata contents - this is where chapters/series info likely lives
if (firstItem.containsKey('metadata')) {
final metadata = firstItem['metadata'] as Map<String, dynamic>?;
if (metadata != null) {
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
for (final key in metadata.keys) {
final value = metadata[key];
if (value is List) {
_logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}');
} else if (value is Map) {
_logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}');
} else if (value != null) {
_logger.log('📚 metadata[$key]: $value');
}
}
}
}
// Check for chapters at top level too
if (firstItem.containsKey('chapters')) {
_logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}');
}
}
final audiobooks = <Audiobook>[]; final audiobooks = <Audiobook>[];
final parseErrors = <String>[];
for (int i = 0; i < items.length; i++) { for (int i = 0; i < items.length; i++) {
try { try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>); final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
audiobooks.add(book); audiobooks.add(book);
} catch (e) { } catch (e) {
parseErrors.add('Item $i: $e'); // Skip items that fail to parse
} }
} }
if (parseErrors.isNotEmpty) {
_logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}');
}
_logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks');
return audiobooks; return audiobooks;
} catch (e, stack) { } catch (e, stack) {
_logger.log('Error getting audiobooks: $e'); _logger.log('Error getting audiobooks: $e');
@@ -1318,6 +1239,7 @@ class MusicAssistantAPI {
); );
final items = response['result'] as List<dynamic>?; final items = response['result'] as List<dynamic>?;
_logger.log('📋 recently_played_items returned ${items?.length ?? 0} items');
if (items == null || items.isEmpty) { if (items == null || items.isEmpty) {
_logger.log('⚠️ No recently played tracks found'); _logger.log('⚠️ No recently played tracks found');
return []; return [];
@@ -1327,6 +1249,8 @@ class MusicAssistantAPI {
final trackUris = <String>[]; final trackUris = <String>[];
for (final item in items) { for (final item in items) {
final trackUri = (item as Map<String, dynamic>)['uri'] as String?; final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
final name = (item as Map<String, dynamic>)['name'] as String?;
_logger.log(' 📀 Track: $name');
if (trackUri != null) { if (trackUri != null) {
trackUris.add(trackUri); trackUris.add(trackUri);
} }
@@ -1356,12 +1280,17 @@ class MusicAssistantAPI {
try { try {
final fullTrack = trackResponse['result'] as Map<String, dynamic>?; final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
final trackName = fullTrack?['name'] as String?;
final albumData = fullTrack?['album'] as Map<String, dynamic>?; final albumData = fullTrack?['album'] as Map<String, dynamic>?;
final albumUri = albumData?['uri'] as String?; final albumUri = albumData?['uri'] as String?;
final albumName = albumData?['name'] as String?;
if (albumUri != null && !seenAlbumUris.contains(albumUri)) { if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
seenAlbumUris.add(albumUri); seenAlbumUris.add(albumUri);
albumUris.add(albumUri); albumUris.add(albumUri);
_logger.log(' 📀 Found album: $albumName from track: $trackName');
} else if (albumUri == null) {
_logger.log(' ⚠️ Track "$trackName" has no album');
} }
} catch (_) { } catch (_) {
continue; continue;
@@ -1630,20 +1559,38 @@ class MusicAssistantAPI {
/// Remove item from favorites /// Remove item from favorites
/// Requires the library_item_id (the numeric ID in the MA library) /// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async { Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
try { await _sendCommand(
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId'); 'music/favorites/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
}
await _sendCommand( // Library
'music/favorites/remove_item', /// Add item to library using URI format
args: { /// The item parameter should be a URI like "spotify://artist/4tZwfgrHOc3mvqYlEYSvVi"
'media_type': mediaType, Future<void> addItemToLibrary(String mediaType, String itemId, String provider) async {
'library_item_id': libraryItemId, final uri = '$provider://$mediaType/$itemId';
}, await _sendCommand(
); 'music/library/add_item',
} catch (e) { args: {
_logger.log('Error removing from favorites: $e'); 'item': uri,
rethrow; },
} );
}
/// Remove item from library
/// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeItemFromLibrary(String mediaType, int libraryItemId) async {
await _sendCommand(
'music/library/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
} }
// Search // Search
@@ -1948,6 +1895,86 @@ class MusicAssistantAPI {
} }
} }
/// Play album on player
Future<void> playAlbum(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Playing album: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing album: $e');
rethrow;
}
}
/// Add album tracks to queue
Future<void> addAlbumToQueue(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Adding album to queue: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding album to queue: $e');
rethrow;
}
}
/// Add artist tracks to queue
Future<void> addArtistToQueue(String playerId, Artist artist) async {
try {
final uri = artist.uri ?? 'library://artist/${artist.itemId}';
_logger.log('🎤 Adding artist to queue: ${artist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding artist to queue: $e');
rethrow;
}
}
/// Play playlist on player
Future<void> playPlaylist(String playerId, Playlist playlist) async {
try {
final uri = playlist.uri ?? 'library://playlist/${playlist.itemId}';
_logger.log('📝 Playing playlist: ${playlist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing playlist: $e');
rethrow;
}
}
/// Play multiple tracks via queue /// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior) /// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued /// If startIndex is provided, only tracks from that index onwards will be queued
@@ -2426,6 +2453,39 @@ class MusicAssistantAPI {
} }
} }
/// Delete an item from the queue
Future<void> queueCommandDeleteItem(String queueId, String itemId) async {
try {
await _sendCommand(
'player_queues/delete_item',
args: {
'queue_id': queueId,
'item_id_or_index': itemId,
},
);
} catch (e) {
_logger.log('Error deleting queue item: $e');
rethrow;
}
}
/// Move an item to a new position in the queue
Future<void> queueCommandMoveItem(String queueId, String itemId, int newIndex) async {
try {
await _sendCommand(
'player_queues/move_item',
args: {
'queue_id': queueId,
'item_id_or_index': itemId,
'new_index': newIndex,
},
);
} catch (e) {
_logger.log('Error moving queue item: $e');
rethrow;
}
}
// ============================================================================ // ============================================================================
// BUILT-IN PLAYER MANAGEMENT // BUILT-IN PLAYER MANAGEMENT
// ============================================================================ // ============================================================================
@@ -2453,6 +2513,22 @@ class MusicAssistantAPI {
return _eventStreams['player_added']!.stream; return _eventStreams['player_added']!.stream;
} }
/// Stream of media_item_added events (for refreshing library when items are added)
Stream<Map<String, dynamic>> get mediaItemAddedEvents {
if (!_eventStreams.containsKey('media_item_added')) {
_eventStreams['media_item_added'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['media_item_added']!.stream;
}
/// Stream of media_item_deleted events (for refreshing library when items are removed)
Stream<Map<String, dynamic>> get mediaItemDeletedEvents {
if (!_eventStreams.containsKey('media_item_deleted')) {
_eventStreams['media_item_deleted'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['media_item_deleted']!.stream;
}
/// Register this device as a player with retry logic /// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json /// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name /// The server expects: player_id and player_name
+55
View File
@@ -35,6 +35,9 @@ class SettingsService {
static const String _keyShowFavoriteAlbums = 'show_favorite_albums'; static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists'; static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks'; static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations';
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
// Default row order // Default row order
@@ -48,6 +51,8 @@ class SettingsService {
'favorite-albums', 'favorite-albums',
'favorite-artists', 'favorite-artists',
'favorite-tracks', 'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
]; ];
// View Mode Settings // View Mode Settings
@@ -83,6 +88,9 @@ class SettingsService {
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
// Volume Precision Mode Settings
static const String _keyVolumePrecisionMode = 'volume_precision_mode'; // Enable hold-to-precision volume control
// Podcast Cover Cache (iTunes URLs for high-res artwork) // Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache'; static const String _keyPodcastCoverCache = 'podcast_cover_cache';
@@ -489,6 +497,37 @@ class SettingsService {
await prefs.setBool(_keyShowFavoriteTracks, show); await prefs.setBool(_keyShowFavoriteTracks, show);
} }
static Future<bool> getShowFavoritePlaylists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
}
static Future<void> setShowFavoritePlaylists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoritePlaylists, show);
}
static Future<bool> getShowFavoriteRadioStations() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
}
static Future<void> setShowFavoriteRadioStations(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteRadioStations, show);
}
// Library Artists Filter - show only artists that have albums in library
static Future<bool> getShowOnlyArtistsWithAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false;
}
static Future<void> setShowOnlyArtistsWithAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show);
}
// Home Row Order // Home Row Order
static Future<List<String>> getHomeRowOrder() async { static Future<List<String>> getHomeRowOrder() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -812,6 +851,22 @@ class SettingsService {
await prefs.setBool(_keyHasCompletedOnboarding, completed); await prefs.setBool(_keyHasCompletedOnboarding, completed);
} }
// Volume Precision Mode Settings
/// Get whether volume precision mode is enabled (default: true)
/// When enabled, holding still while adjusting volume enters precision mode
/// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
}
/// Set whether volume precision mode is enabled
static Future<void> setVolumePrecisionMode(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyVolumePrecisionMode, enabled);
}
// Podcast Cover Cache (iTunes URLs for high-res artwork) // Podcast Cover Cache (iTunes URLs for high-res artwork)
// Stored as JSON: {"podcastId": "itunesUrl", ...} // Stored as JSON: {"podcastId": "itunesUrl", ...}
+12 -1
View File
@@ -5,6 +5,7 @@ import '../models/media_item.dart';
import 'database_service.dart'; import 'database_service.dart';
import 'debug_logger.dart'; import 'debug_logger.dart';
import 'music_assistant_api.dart'; import 'music_assistant_api.dart';
import 'settings_service.dart';
/// Sync status for UI indicators /// Sync status for UI indicators
enum SyncStatus { enum SyncStatus {
@@ -150,10 +151,14 @@ class SyncService with ChangeNotifier {
try { try {
_logger.log('🔄 Starting background library sync...'); _logger.log('🔄 Starting background library sync...');
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
// Fetch fresh data from MA API (in parallel for speed) // Fetch fresh data from MA API (in parallel for speed)
final results = await Future.wait([ final results = await Future.wait([
api.getAlbums(limit: 1000), api.getAlbums(limit: 1000),
api.getArtists(limit: 1000), api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000), api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000), api.getPlaylists(limit: 1000),
]); ]);
@@ -166,6 +171,12 @@ class SyncService with ChangeNotifier {
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, ' _logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA'); '${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
// Clear old cache before saving fresh data (removes stale items)
await _db.clearCacheForType('album');
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
// Save to database cache // Save to database cache
await _saveAlbumsToCache(albums); await _saveAlbumsToCache(albums);
await _saveArtistsToCache(artists); await _saveArtistsToCache(artists);
+15
View File
@@ -61,6 +61,7 @@ class ThemeProvider extends ChangeNotifier {
/// Get the current adaptive surface color (for bottom nav background, etc.) /// Get the current adaptive surface color (for bottom nav background, etc.)
/// Returns a subtle tinted surface based on the adaptive colors /// Returns a subtle tinted surface based on the adaptive colors
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
Color? get adaptiveSurfaceColor { Color? get adaptiveSurfaceColor {
if (_adaptiveColors == null) return null; if (_adaptiveColors == null) return null;
// Use the miniPlayer color but darkened for a subtle tinted background // Use the miniPlayer color but darkened for a subtle tinted background
@@ -68,6 +69,20 @@ class ThemeProvider extends ChangeNotifier {
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor(); return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
} }
/// Get adaptive surface color for the given brightness (light/dark mode aware)
/// Returns the same color as the expanded player background (scheme.surface)
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
// Pick scheme based on current mode
final scheme = brightness == Brightness.dark
? _adaptiveDarkScheme
: _adaptiveLightScheme;
if (scheme == null) return null;
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
return scheme.surface;
}
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final themeModeString = await SettingsService.getThemeMode(); final themeModeString = await SettingsService.getThemeMode();
_themeMode = _parseThemeMode(themeModeString); _themeMode = _parseThemeMode(themeModeString);
+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());
}
}
+27
View File
@@ -31,6 +31,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
late Future<List<Audiobook>> _audiobooksFuture; late Future<List<Audiobook>> _audiobooksFuture;
List<Audiobook>? _cachedAudiobooks; List<Audiobook>? _cachedAudiobooks;
bool _hasLoaded = false; bool _hasLoaded = false;
bool _hasPrecachedAudiobooks = false;
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -45,12 +46,38 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
if (!_hasLoaded) { if (!_hasLoaded) {
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) { _audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
_cachedAudiobooks = audiobooks; _cachedAudiobooks = audiobooks;
// Pre-cache images for smooth Hero animations
_precacheAudiobookImages(audiobooks);
return audiobooks; return audiobooks;
}); });
_hasLoaded = true; _hasLoaded = true;
} }
} }
/// Pre-cache audiobook images so Hero animations are smooth on first tap
void _precacheAudiobookImages(List<Audiobook> audiobooks) {
if (!mounted || _hasPrecachedAudiobooks) return;
_hasPrecachedAudiobooks = true;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items
final audiobooksToCache = audiobooks.take(10);
for (final audiobook in audiobooksToCache) {
final imageUrl = maProvider.api?.getImageUrl(audiobook, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger(); static final _logger = DebugLogger();
@override @override
+403 -128
View File
@@ -11,6 +11,7 @@ import '../services/animation_debugger.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../services/settings_service.dart';
import 'animated_icon_button.dart'; import 'animated_icon_button.dart';
import 'global_player_overlay.dart'; import 'global_player_overlay.dart';
import 'volume_control.dart'; import 'volume_control.dart';
@@ -76,6 +77,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0); final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null); final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
// Pre-computed static colors to avoid object creation during animation frames
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
// Dimensions // Dimensions
static double get _collapsedHeight => MiniPlayerLayout.height; static double get _collapsedHeight => MiniPlayerLayout.height;
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar) static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
@@ -92,7 +96,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Slide animation for device switching - now supports finger-following // Slide animation for device switching - now supports finger-following
late AnimationController _slideController; late AnimationController _slideController;
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right // ValueNotifier avoids setState during high-frequency drag updates
final ValueNotifier<double> _slideOffsetNotifier = ValueNotifier(0.0);
double get _slideOffset => _slideOffsetNotifier.value;
set _slideOffset(double value) => _slideOffsetNotifier.value = value;
bool _isSliding = false; bool _isSliding = false;
bool _isDragging = false; // True while finger is actively dragging bool _isDragging = false; // True while finger is actively dragging
@@ -108,16 +115,31 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
bool _isDraggingVolume = false; bool _isDraggingVolume = false;
double _dragVolumeLevel = 0.0; double _dragVolumeLevel = 0.0;
int _lastVolumeUpdateTime = 0; int _lastVolumeUpdateTime = 0;
int _lastVolumeDragEndTime = 0; // Track when last drag ended for consecutive swipes
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
// Volume precision mode state
bool _inVolumePrecisionMode = false;
Timer? _volumePrecisionTimer;
Offset? _lastVolumeDragPosition;
double _lastVolumeLocalX = 0.0; // Last local X position during drag
bool _volumePrecisionModeEnabled = true; // From settings
double _volumePrecisionZoomCenter = 0.0; // Volume level when precision mode started
double _volumePrecisionStartX = 0.0; // Finger X position when precision mode started
static const int _precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
static const double _precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
static const double _precisionSensitivity = 0.1; // Zoomed range (10% = left edge to right edge)
// Track favorite state for current track // Track favorite state for current track
bool _isCurrentTrackFavorite = false; bool _isCurrentTrackFavorite = false;
String? _lastTrackUri; // Track which track we last checked favorite status for String? _lastTrackUri; // Track which track we last checked favorite status for
// Cached title height to avoid TextPainter.layout() every animation frame // Cached title height to avoid TextPainter.layout() every animation frame
// Only invalidate when track name changes (screen width doesn't change during animation)
double? _cachedExpandedTitleHeight; double? _cachedExpandedTitleHeight;
String? _lastMeasuredTrackName; String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth;
// Gesture-driven expansion state // Gesture-driven expansion state
bool _isVerticalDragging = false; bool _isVerticalDragging = false;
@@ -176,6 +198,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_loadQueue(); _loadQueue();
} else if (status == AnimationStatus.dismissed) { } else if (status == AnimationStatus.dismissed) {
// Close queue panel when player collapses // Close queue panel when player collapses
_queuePanelController.duration = _queueCloseDuration;
_queuePanelController.reverse(); _queuePanelController.reverse();
} }
}); });
@@ -191,6 +214,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_stopQueueRefreshTimer(); _stopQueueRefreshTimer();
} }
}); });
// Load precision mode setting
_loadVolumePrecisionModeSetting();
}
Future<void> _loadVolumePrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
_volumePrecisionModeEnabled = enabled;
}
}
void _enterVolumePrecisionMode() {
if (_inVolumePrecisionMode) return;
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
setState(() {
_inVolumePrecisionMode = true;
_volumePrecisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
_volumePrecisionStartX = _lastVolumeLocalX; // Capture finger position at entry
});
}
void _exitVolumePrecisionMode() {
_volumePrecisionTimer?.cancel();
_volumePrecisionTimer = null;
if (!_inVolumePrecisionMode) return;
setState(() {
_inVolumePrecisionMode = false;
});
} }
Timer? _queueRefreshTimer; Timer? _queueRefreshTimer;
@@ -215,8 +267,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_controller.dispose(); _controller.dispose();
_queuePanelController.dispose(); _queuePanelController.dispose();
_slideController.dispose(); _slideController.dispose();
_slideOffsetNotifier.dispose();
_positionSubscription?.cancel(); _positionSubscription?.cancel();
_queueRefreshTimer?.cancel(); _queueRefreshTimer?.cancel();
_volumePrecisionTimer?.cancel();
_progressNotifier.dispose(); _progressNotifier.dispose();
_seekPositionNotifier.dispose(); _seekPositionNotifier.dispose();
super.dispose(); super.dispose();
@@ -229,6 +283,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Animation durations - asymmetric for snappier collapse // Animation durations - asymmetric for snappier collapse
static const Duration _expandDuration = Duration(milliseconds: 280); static const Duration _expandDuration = Duration(milliseconds: 280);
static const Duration _collapseDuration = Duration(milliseconds: 200); static const Duration _collapseDuration = Duration(milliseconds: 200);
static const Duration _queueOpenDuration = Duration(milliseconds: 250);
static const Duration _queueCloseDuration = Duration(milliseconds: 180);
void expand() { void expand() {
if (_isVerticalDragging) return; if (_isVerticalDragging) return;
@@ -241,9 +297,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void collapse() { void collapse() {
if (_isVerticalDragging) return; if (_isVerticalDragging) return;
// If queue panel is open, close it first instead of collapsing player
// This prevents both from closing on a single back press
if (isQueuePanelOpen) {
closeQueuePanel();
return;
}
AnimationDebugger.startSession('playerCollapse'); AnimationDebugger.startSession('playerCollapse');
// Instantly hide queue panel when collapsing to avoid visual glitches // Instantly hide queue panel when collapsing to avoid visual glitches
// during Android's predictive back gesture // during Android's predictive back gesture (only reached if queue already closed)
_queuePanelController.value = 0; _queuePanelController.value = 0;
_controller.duration = _collapseDuration; _controller.duration = _collapseDuration;
_controller.reverse().then((_) { _controller.reverse().then((_) {
@@ -514,14 +576,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void _toggleQueuePanel() { void _toggleQueuePanel() {
if (_queuePanelController.isAnimating) return; if (_queuePanelController.isAnimating) return;
if (_queuePanelController.value == 0) { if (_queuePanelController.value == 0) {
_queuePanelController.duration = _queueOpenDuration;
_queuePanelController.forward(); _queuePanelController.forward();
} else { } else {
_queuePanelController.duration = _queueCloseDuration;
_queuePanelController.reverse(); _queuePanelController.reverse();
} }
} }
bool get isQueuePanelOpen => _queuePanelController.value > 0.5; bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
/// Close queue panel if open (for external access via GlobalPlayerOverlay)
void closeQueuePanel() {
if (isQueuePanelOpen && !_queuePanelController.isAnimating) {
_queuePanelController.duration = _queueCloseDuration;
_queuePanelController.reverse();
}
}
/// Update favorite status when track changes /// Update favorite status when track changes
void _updateFavoriteStatus(dynamic currentTrack) { void _updateFavoriteStatus(dynamic currentTrack) {
if (currentTrack == null) { if (currentTrack == null) {
@@ -583,9 +655,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final mappings = currentTrack.providerMappings as List<dynamic>?; final mappings = currentTrack.providerMappings as List<dynamic>?;
if (mappings != null && mappings.isNotEmpty) { if (mappings != null && mappings.isNotEmpty) {
// Find a non-library provider mapping // Find a non-library provider mapping
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
for (final mapping in mappings) { for (final mapping in mappings) {
if (mapping.available == true && mapping.providerInstance != 'library') { if (mapping.available == true && mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance; actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId; actualItemId = mapping.itemId;
break; break;
} }
@@ -594,7 +667,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (actualProvider == 'library') { if (actualProvider == 'library') {
for (final mapping in mappings) { for (final mapping in mappings) {
if (mapping.available == true) { if (mapping.available == true) {
actualProvider = mapping.providerInstance; actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId; actualItemId = mapping.itemId;
break; break;
} }
@@ -770,32 +843,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final normalizedDelta = delta / containerWidth; final normalizedDelta = delta / containerWidth;
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0); final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
// Update peek player based on drag direction (must be inside setState to trigger rebuild) // Update slideOffset via ValueNotifier (no setState needed - avoids 60fps rebuilds)
setState(() { _slideOffset = newSlideOffset;
_slideOffset = newSlideOffset;
if (_slideOffset != 0) { // Only trigger setState when peek player changes (not every frame)
_updatePeekPlayerState(maProvider, _slideOffset); if (_slideOffset != 0) {
} _updatePeekPlayerState(maProvider, _slideOffset);
}); }
} }
/// Update peek player state variables (called inside setState) /// Update peek player state variables - only calls setState when peek player changes
void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) { void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) {
// dragDirection < 0 means swiping left (next player) // dragDirection < 0 means swiping left (next player)
// dragDirection > 0 means swiping right (previous player) // dragDirection > 0 means swiping right (previous player)
final isNext = dragDirection < 0; final isNext = dragDirection < 0;
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext); final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
// Only setState when peek player actually changes (prevents unnecessary rebuilds)
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) { if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
_peekPlayer = newPeekPlayer; setState(() {
// Get the peek player's current track image if available _peekPlayer = newPeekPlayer;
if (_peekPlayer != null) { // Get the peek player's current track image if available
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId); if (_peekPlayer != null) {
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null; _peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
} else { _peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
_peekTrack = null; } else {
_peekImageUrl = null; _peekTrack = null;
} _peekImageUrl = null;
}
});
} }
} }
@@ -850,9 +926,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void animateToTarget() { void animateToTarget() {
if (!mounted) return; if (!mounted) return;
final curvedValue = Curves.easeOutCubic.transform(_slideController.value); final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
setState(() { // ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue; _slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
});
} }
_slideController.addListener(animateToTarget); _slideController.addListener(animateToTarget);
@@ -878,8 +953,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Move peek content to center position (slideOffset = 0) while keeping it visible. // Move peek content to center position (slideOffset = 0) while keeping it visible.
// The main content is hidden via _inTransition flag. // The main content is hidden via _inTransition flag.
// This creates a seamless visual - peek content is already at center. // This creates a seamless visual - peek content is already at center.
_slideOffset = 0.0; // ValueNotifier triggers AnimatedBuilder rebuild
setState(() { setState(() {
_slideOffset = 0.0;
_isSliding = false; _isSliding = false;
// Keep _inTransition = true and peek data intact // Keep _inTransition = true and peek data intact
}); });
@@ -973,19 +1048,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
void animateBack() { void animateBack() {
if (!mounted) return; if (!mounted) return;
final curvedValue = Curves.easeOutBack.transform(_slideController.value); final curvedValue = Curves.easeOutBack.transform(_slideController.value);
setState(() { // ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
_slideOffset = startOffset * (1.0 - curvedValue); _slideOffset = startOffset * (1.0 - curvedValue);
});
} }
_slideController.addListener(animateBack); _slideController.addListener(animateBack);
_slideController.duration = const Duration(milliseconds: 300); _slideController.duration = const Duration(milliseconds: 220); // Snappier snap-back
_slideController.forward().then((_) { _slideController.forward().then((_) {
if (!mounted) return; if (!mounted) return;
_slideController.removeListener(animateBack); _slideController.removeListener(animateBack);
// Update slideOffset via ValueNotifier (triggers rebuild)
_slideOffset = 0.0;
// Other state changes still need setState
setState(() { setState(() {
_slideOffset = 0.0;
_isSliding = false; _isSliding = false;
_peekPlayer = null; _peekPlayer = null;
_peekImageUrl = null; _peekImageUrl = null;
@@ -1039,22 +1115,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}); });
} }
return AnimatedBuilder( // Handle Android back button - close queue panel first, then collapse player
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]), // Guard against re-entry during animations to prevent double-close
builder: (context, _) { return PopScope(
// If no track is playing, show device selector bar canPop: !isQueuePanelOpen && !isExpanded,
if (currentTrack == null) { onPopInvokedWithResult: (didPop, result) {
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider); if (!didPop) {
// Ignore if already animating (prevents double-processing)
if (_queuePanelController.isAnimating || _controller.isAnimating) return;
if (isQueuePanelOpen) {
_toggleQueuePanel();
} else if (isExpanded) {
collapse();
}
} }
return _buildMorphingPlayer(
context,
maProvider,
selectedPlayer,
currentTrack,
imageUrl,
themeProvider,
);
}, },
child: AnimatedBuilder(
// Include _slideOffsetNotifier to rebuild during finger-following drags
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation, _slideOffsetNotifier]),
builder: (context, _) {
// If no track is playing, show device selector bar
if (currentTrack == null) {
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
}
return _buildMorphingPlayer(
context,
maProvider,
selectedPlayer,
currentTrack,
imageUrl,
themeProvider,
);
},
),
); );
}, },
); );
@@ -1068,7 +1162,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
ThemeProvider themeProvider, ThemeProvider themeProvider,
) { ) {
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.of(context).size;
final bottomPadding = MediaQuery.of(context).padding.bottom; // Use viewPadding to match BottomNavigationBar's height calculation
final bottomPadding = MediaQuery.of(context).viewPadding.bottom;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -1158,7 +1253,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
ThemeProvider themeProvider, ThemeProvider themeProvider,
) { ) {
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.of(context).size;
final bottomPadding = MediaQuery.of(context).padding.bottom; // Use viewPadding (not padding) to match BottomNavigationBar's height calculation
// viewPadding represents permanent system UI, padding can change (e.g., keyboard)
final bottomPadding = MediaQuery.of(context).viewPadding.bottom;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -1191,16 +1288,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
? adaptiveScheme.onPrimaryContainer ? adaptiveScheme.onPrimaryContainer
: colorScheme.onPrimaryContainer; : colorScheme.onPrimaryContainer;
final expandedTextColor = adaptiveScheme?.onSurface ?? Colors.white; // Use colorScheme.onSurface as fallback instead of Colors.white for light theme support
final expandedTextColor = adaptiveScheme?.onSurface ?? colorScheme.onSurface;
final textColor = Color.lerp(collapsedTextColor, expandedTextColor, t)!; final textColor = Color.lerp(collapsedTextColor, expandedTextColor, t)!;
final primaryColor = adaptiveScheme?.primary ?? Colors.white; // Use colorScheme.primary as fallback instead of Colors.white for light theme support
final primaryColor = adaptiveScheme?.primary ?? colorScheme.primary;
// Always position above bottom nav bar // Always position above bottom nav bar
// Overlap by 2px when expanded to eliminate any subpixel rendering gaps
final bottomNavSpace = _bottomNavHeight + bottomPadding; final bottomNavSpace = _bottomNavHeight + bottomPadding;
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin; final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
final expandedBottomOffset = bottomNavSpace; final expandedBottomOffset = bottomNavSpace - 2;
final expandedHeight = screenSize.height - bottomNavSpace; final expandedHeight = screenSize.height - bottomNavSpace + 2;
// Apply slide offset to hide mini player (slides down off-screen) // Apply slide offset to hide mini player (slides down off-screen)
// Apply bounce offset for reveal animation (small downward movement) // Apply bounce offset for reveal animation (small downward movement)
@@ -1271,9 +1371,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
); );
// Only recalculate if track name or width changed // Only recalculate when track changes (screen width doesn't change during animation)
if (_lastMeasuredTrackName != currentTrack.name || if (_lastMeasuredTrackName != currentTrack.name ||
_lastMeasuredTitleWidth != expandedTitleWidth ||
_cachedExpandedTitleHeight == null) { _cachedExpandedTitleHeight == null) {
final titlePainter = TextPainter( final titlePainter = TextPainter(
text: TextSpan(text: currentTrack.name, style: titleStyle), text: TextSpan(text: currentTrack.name, style: titleStyle),
@@ -1282,7 +1381,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
)..layout(maxWidth: expandedTitleWidth); )..layout(maxWidth: expandedTitleWidth);
_cachedExpandedTitleHeight = titlePainter.height; _cachedExpandedTitleHeight = titlePainter.height;
_lastMeasuredTrackName = currentTrack.name; _lastMeasuredTrackName = currentTrack.name;
_lastMeasuredTitleWidth = expandedTitleWidth;
} }
final expandedTitleHeight = _cachedExpandedTitleHeight!; final expandedTitleHeight = _cachedExpandedTitleHeight!;
@@ -1302,7 +1400,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// - Progress bar + times: ~70px // - Progress bar + times: ~70px
// Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding // Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding
// Position progress bar so controls section is anchored at bottom // Position progress bar so controls section is anchored at bottom
final bottomSafeArea = MediaQuery.of(context).padding.bottom; // Use viewPadding for consistency with player height calculation
final bottomSafeArea = MediaQuery.of(context).viewPadding.bottom;
final controlsSectionHeight = 222.0; // Total height of progress + controls + volume final controlsSectionHeight = 222.0; // Total height of progress + controls + volume
final playButtonHalfHeight = 36.0; // Half of the 72px play button container final playButtonHalfHeight = 36.0; // Half of the 72px play button container
final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight; final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight;
@@ -1405,26 +1504,76 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Finish gesture-driven expansion // Finish gesture-driven expansion
_handleVerticalDragEnd(details); _handleVerticalDragEnd(details);
}, },
onVerticalDragCancel: () {
// Reset state if gesture is cancelled (e.g., system takes over)
_isVerticalDragging = false;
},
onHorizontalDragStart: (details) { onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx; _horizontalDragStartX = details.globalPosition.dx;
// Start volume drag if device reveal is visible // Start volume drag if device reveal is visible
if (widget.isDeviceRevealVisible && !isExpanded) { if (widget.isDeviceRevealVisible && !isExpanded) {
final currentVolume = (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0; // For consecutive swipes, use local volume (API may not have updated player state yet)
final now = DateTime.now().millisecondsSinceEpoch;
final timeSinceLastDrag = now - _lastVolumeDragEndTime;
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
// Use local volume if we have an override AND within window
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
final startVolume = useLocalVolume
? _dragVolumeLevel // Continue from where last swipe ended
: (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0; // Fresh from player
setState(() { setState(() {
_isDraggingVolume = true; _isDraggingVolume = true;
_dragVolumeLevel = currentVolume; _dragVolumeLevel = startVolume;
}); });
_lastVolumeDragPosition = details.globalPosition;
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
} }
}, },
onHorizontalDragUpdate: (details) { onHorizontalDragUpdate: (details) {
// Volume swipe when device reveal is visible // Volume swipe when device reveal is visible
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) { if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
// Directional volume: drag right = increase, drag left = decrease // Check for stillness to trigger precision mode (only if enabled in settings)
// Multiple swipes accumulate - each starts from current volume final currentPosition = details.globalPosition;
final dragDelta = details.delta.dx; if (_volumePrecisionModeEnabled && _lastVolumeDragPosition != null) {
final volumeDelta = dragDelta / collapsedWidth; final movement = (currentPosition - _lastVolumeDragPosition!).distance;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
if (movement < _precisionStillnessThreshold) {
// Finger is still - start precision timer if not already running
if (_volumePrecisionTimer == null && !_inVolumePrecisionMode) {
_volumePrecisionTimer = Timer(
Duration(milliseconds: _precisionTriggerMs),
_enterVolumePrecisionMode,
);
}
} else {
// Finger moved - cancel timer (but don't exit precision mode if already in it)
_volumePrecisionTimer?.cancel();
_volumePrecisionTimer = null;
}
}
_lastVolumeDragPosition = currentPosition;
_lastVolumeLocalX = details.localPosition.dx;
double newVolume;
if (_inVolumePrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
// Full width of movement = precisionSensitivity (10%) change
// e.g., at 40% center: moving full width right = 50%, full width left = 30%
final offsetX = details.localPosition.dx - _volumePrecisionStartX;
final normalizedOffset = offsetX / collapsedWidth; // -1.0 to +1.0 range
final volumeChange = normalizedOffset * _precisionSensitivity;
newVolume = (_volumePrecisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Delta-based movement (full width = 100%)
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / collapsedWidth;
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
}
if ((newVolume - _dragVolumeLevel).abs() > 0.001) { if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
setState(() { setState(() {
_dragVolumeLevel = newVolume; _dragVolumeLevel = newVolume;
@@ -1456,6 +1605,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (_isDraggingVolume) { if (_isDraggingVolume) {
// Send final volume on release // Send final volume on release
maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round()); maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round());
_lastVolumeDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
_exitVolumePrecisionMode();
_lastVolumeDragPosition = null;
setState(() { setState(() {
_isDraggingVolume = false; _isDraggingVolume = false;
}); });
@@ -1487,6 +1640,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider); _handleHorizontalDragEnd(details, maProvider);
} }
}, },
onHorizontalDragCancel: () {
// Reset state if gesture is cancelled (e.g., system takes over)
_horizontalDragStartX = null;
if (_isDraggingVolume) {
_exitVolumePrecisionMode();
_lastVolumeDragPosition = null;
setState(() => _isDraggingVolume = false);
}
if (_isDragging) {
_isDragging = false;
_slideOffset = 0.0;
setState(() {
_peekPlayer = null;
_peekImageUrl = null;
_peekTrack = null;
});
}
},
child: Container( child: Container(
// Use foregroundDecoration for border so it renders ON TOP of content // Use foregroundDecoration for border so it renders ON TOP of content
// This prevents the album art from clipping the yellow synced border // This prevents the album art from clipping the yellow synced border
@@ -1500,8 +1671,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
elevation: _lerpDouble(4, 0, t), elevation: _lerpDouble(4, 0, t),
shadowColor: Colors.black.withOpacity(0.3), shadowColor: _shadowColor,
clipBehavior: Clip.antiAlias, // Use hardEdge when fully expanded (no border radius) to avoid anti-alias artifacts
// Use antiAlias when collapsed/animating to smooth rounded corners
clipBehavior: borderRadius > 0.5 ? Clip.antiAlias : Clip.hardEdge,
child: SizedBox( child: SizedBox(
width: width, width: width,
height: height, height: height,
@@ -1529,18 +1702,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Fade out as we expand - use color alpha instead of Opacity widget // Fade out as we expand - use color alpha instead of Opacity widget
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0); final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
final progressAreaWidth = width - _collapsedArtSize; final progressAreaWidth = width - _collapsedArtSize;
return ClipRRect( // RepaintBoundary isolates frequent progress updates from parent animation
borderRadius: BorderRadius.only( return RepaintBoundary(
topRight: Radius.circular(borderRadius), child: ClipRRect(
bottomRight: Radius.circular(borderRadius), borderRadius: BorderRadius.only(
), topRight: Radius.circular(borderRadius),
clipBehavior: Clip.hardEdge, bottomRight: Radius.circular(borderRadius),
child: Align( ),
alignment: Alignment.centerLeft, clipBehavior: Clip.hardEdge,
child: Container( child: Align(
width: progressAreaWidth * progress, alignment: Alignment.centerLeft,
height: height, child: Container(
color: collapsedBg.withOpacity(progressOpacity), width: progressAreaWidth * progress,
height: height,
color: collapsedBg.withOpacity(progressOpacity),
),
), ),
), ),
); );
@@ -1764,7 +1940,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Positioned( Positioned(
left: contentPadding, left: contentPadding,
right: contentPadding, right: contentPadding,
top: expandedAlbumTop + (currentTrack.album != null ? 24 : 0), top: expandedAlbumTop + (maProvider.isPlayingAudiobook ? 24 : (currentTrack.album != null ? 24 : 0)),
child: FadeTransition( child: FadeTransition(
opacity: _expandAnimation.drive( opacity: _expandAnimation.drive(
Tween(begin: 0.0, end: 1.0).chain( Tween(begin: 0.0, end: 1.0).chain(
@@ -1893,18 +2069,70 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset, right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
top: controlsTop, top: controlsTop,
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast) child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
? _buildAudiobookControls( // When device reveal is visible (player list shown), use compact controls
maProvider: maProvider, // for audiobooks/podcasts: Play, Forward 30, Power
selectedPlayer: selectedPlayer, ? widget.isDeviceRevealVisible && t < 0.5
textColor: textColor, ? Row(
primaryColor: primaryColor, mainAxisSize: MainAxisSize.min,
backgroundColor: backgroundColor, mainAxisAlignment: MainAxisAlignment.end,
skipButtonSize: skipButtonSize, children: [
playButtonSize: playButtonSize, // Play/Pause - compact like PlayerCard
playButtonContainerSize: playButtonContainerSize, Transform.translate(
t: t, offset: const Offset(3, 0),
expandedElementsOpacity: expandedElementsOpacity, child: SizedBox(
) width: 44,
height: 44,
child: IconButton(
icon: Icon(
selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: () => maProvider.playPauseSelectedPlayer(),
padding: EdgeInsets.zero,
),
),
),
// Forward 30 seconds
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.forward_30_rounded,
color: textColor,
size: 28,
),
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
// Power button
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: selectedPlayer.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
),
onPressed: () => maProvider.togglePower(selectedPlayer.playerId),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
)
: _buildAudiobookControls(
maProvider: maProvider,
selectedPlayer: selectedPlayer,
textColor: textColor,
primaryColor: primaryColor,
backgroundColor: backgroundColor,
skipButtonSize: skipButtonSize,
playButtonSize: playButtonSize,
playButtonContainerSize: playButtonContainerSize,
t: t,
expandedElementsOpacity: expandedElementsOpacity,
)
// When device reveal is visible (player list shown), use compact controls // When device reveal is visible (player list shown), use compact controls
// like PlayerCard: Play, Next, Power - matching other players in the list // like PlayerCard: Play, Next, Power - matching other players in the list
: widget.isDeviceRevealVisible && t < 0.5 : widget.isDeviceRevealVisible && t < 0.5
@@ -2058,19 +2286,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Favorite button (expanded only) - hide when queue panel is open // Favorite button (expanded only) - hide when queue panel is open
// GPU PERF: Use icon color alpha instead of Opacity // GPU PERF: Use icon color alpha instead of Opacity
// Scale pop animation when toggling favorite
if (t > 0.3 && queueT < 0.5) if (t > 0.3 && queueT < 0.5)
Positioned( Positioned(
top: topPadding + 4, top: topPadding + 4,
right: 52, right: 52,
child: IconButton( child: TweenAnimationBuilder<double>(
icon: Icon( key: ValueKey(_isCurrentTrackFavorite),
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border, tween: Tween(begin: 1.3, end: 1.0),
color: (_isCurrentTrackFavorite ? Colors.red : textColor) duration: const Duration(milliseconds: 200),
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)), curve: Curves.easeOutBack,
size: 24, builder: (context, scale, child) => Transform.scale(
scale: scale,
child: child,
),
child: IconButton(
icon: Icon(
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
size: 24,
),
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
padding: const EdgeInsets.all(12),
), ),
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
padding: const EdgeInsets.all(12),
), ),
), ),
@@ -2153,24 +2392,32 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Volume swipe overlay (covers entire mini player when dragging volume) // Volume swipe overlay (covers entire mini player when dragging volume)
// Only visible when device reveal is open and user is dragging // Only visible when device reveal is open and user is dragging
// Positioned last in Stack so it renders ON TOP of all content // Positioned last in Stack so it renders ON TOP of all content
if (_isDraggingVolume && t < 0.5) // Uses AnimatedOpacity for smooth fade in/out transition
if (t < 0.5)
Positioned.fill( Positioned.fill(
child: ClipRRect( child: IgnorePointer(
borderRadius: BorderRadius.circular(borderRadius), ignoring: !_isDraggingVolume,
child: Stack( child: AnimatedOpacity(
children: [ opacity: _isDraggingVolume ? 1.0 : 0.0,
// Unfilled (darker) background duration: const Duration(milliseconds: 120),
Container(color: collapsedBgUnplayed), child: ClipRRect(
// Filled (lighter) portion based on volume borderRadius: BorderRadius.circular(borderRadius),
Align( child: Stack(
alignment: Alignment.centerLeft, children: [
child: FractionallySizedBox( // Unfilled (darker) background
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0), Container(color: collapsedBgUnplayed),
heightFactor: 1.0, // Filled (lighter) portion based on volume
child: Container(color: collapsedBg), Align(
), alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
heightFactor: 1.0,
child: Container(color: collapsedBg),
),
),
],
), ),
], ),
), ),
), ),
), ),
@@ -2283,11 +2530,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
} }
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) { Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
// Use theme-aware colors for both collapsed and expanded states
final expandedBgColor = colorScheme.surfaceContainerHighest;
final expandedIconColor = colorScheme.onSurface.withOpacity(0.24);
return Container( return Container(
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t), color: Color.lerp(colorScheme.surfaceVariant, expandedBgColor, t),
child: Icon( child: Icon(
Icons.music_note_rounded, Icons.music_note_rounded,
color: Color.lerp(colorScheme.onSurfaceVariant, Colors.white24, t), color: Color.lerp(colorScheme.onSurfaceVariant, expandedIconColor, t),
size: _lerpDouble(24, 120, t), size: _lerpDouble(24, 120, t),
), ),
); );
@@ -2335,7 +2585,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
} }
/// Build audiobook-specific playback controls /// Build audiobook-specific playback controls
/// Layout: [Prev Chapter] [-30s] [Play/Pause] [+30s] [Next Chapter] /// Collapsed: [-30s] [Play/Pause] [+30s]
/// Expanded: [Prev Chapter] [-30s] [-10s] [Play/Pause] [+10s] [+30s] [Next Chapter]
Widget _buildAudiobookControls({ Widget _buildAudiobookControls({
required MusicAssistantProvider maProvider, required MusicAssistantProvider maProvider,
required dynamic selectedPlayer, required dynamic selectedPlayer,
@@ -2349,19 +2600,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
required double expandedElementsOpacity, required double expandedElementsOpacity,
}) { }) {
final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false; final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false;
final isExpanded = t > 0.5;
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end, mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [ children: [
// Previous Chapter (expanded only, if chapters available) // Previous Chapter (expanded only, if chapters available)
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters) if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
_buildSecondaryButton( _buildSecondaryButton(
icon: Icons.skip_previous_rounded, icon: Icons.skip_previous_rounded,
color: textColor.withOpacity(expandedElementsOpacity), color: textColor.withOpacity(expandedElementsOpacity),
onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId), onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId),
), ),
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)), if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
// Rewind 30 seconds // Rewind 30 seconds
_buildControlButton( _buildControlButton(
@@ -2369,9 +2621,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
color: textColor, color: textColor,
size: skipButtonSize, size: skipButtonSize,
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30), onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30),
useAnimation: t > 0.5, useAnimation: isExpanded,
), ),
SizedBox(width: _lerpDouble(0, 20, t)),
// Rewind 10 seconds (expanded only, same size as 30s)
if (isExpanded) ...[
SizedBox(width: _lerpDouble(0, 8, t)),
_buildControlButton(
icon: Icons.replay_10_rounded,
color: textColor,
size: skipButtonSize,
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -10),
useAnimation: true,
),
],
SizedBox(width: _lerpDouble(0, 12, t)),
// Play/Pause // Play/Pause
_buildPlayButton( _buildPlayButton(
@@ -2385,7 +2649,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
onPressed: () => maProvider.playPauseSelectedPlayer(), onPressed: () => maProvider.playPauseSelectedPlayer(),
onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId), onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId),
), ),
SizedBox(width: _lerpDouble(0, 20, t)), SizedBox(width: _lerpDouble(0, 12, t)),
// Forward 10 seconds (expanded only, same size as 30s)
if (isExpanded) ...[
_buildControlButton(
icon: Icons.forward_10_rounded,
color: textColor,
size: skipButtonSize,
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 10),
useAnimation: true,
),
SizedBox(width: _lerpDouble(0, 8, t)),
],
// Forward 30 seconds // Forward 30 seconds
_buildControlButton( _buildControlButton(
@@ -2393,12 +2669,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
color: textColor, color: textColor,
size: skipButtonSize, size: skipButtonSize,
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30), onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
useAnimation: t > 0.5, useAnimation: isExpanded,
), ),
// Next Chapter (expanded only, if chapters available) // Next Chapter (expanded only, if chapters available)
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)), if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters) if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
_buildSecondaryButton( _buildSecondaryButton(
icon: Icons.skip_next_rounded, icon: Icons.skip_next_rounded,
color: textColor.withOpacity(expandedElementsOpacity), color: textColor.withOpacity(expandedElementsOpacity),
@@ -2441,12 +2717,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
return SizedBox( return SizedBox(
width: 44, width: 44,
height: 44, height: 44,
child: IconButton( child: AnimatedIconButton(
icon: Icon(icon), icon: icon,
color: color, color: color,
iconSize: 22, iconSize: 22,
onPressed: onPressed, onPressed: onPressed,
padding: EdgeInsets.zero,
), ),
); );
} }
+17 -4
View File
@@ -99,6 +99,15 @@ class GlobalPlayerOverlay extends StatefulWidget {
static bool get isPlayerExpanded => static bool get isPlayerExpanded =>
globalPlayerKey.currentState?.isExpanded ?? false; globalPlayerKey.currentState?.isExpanded ?? false;
/// Check if the queue panel is currently open
static bool get isQueuePanelOpen =>
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
/// Close the queue panel if open
static void closeQueuePanel() {
globalPlayerKey.currentState?.closeQueuePanel();
}
/// Get the current expansion progress (0.0 to 1.0) /// Get the current expansion progress (0.0 to 1.0)
static double get expansionProgress => static double get expansionProgress =>
globalPlayerKey.currentState?.expansionProgress ?? 0.0; globalPlayerKey.currentState?.expansionProgress ?? 0.0;
@@ -483,8 +492,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark); final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Base background: use adaptive surface color if available, otherwise default surface // Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null) // Use brightness-aware method to respect light/dark mode
? themeProvider.adaptiveSurfaceColor! final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
final baseBgColor = (themeProvider.adaptiveTheme && adaptiveBg != null)
? adaptiveBg
: colorScheme.surface; : colorScheme.surface;
return ValueListenableBuilder<PlayerExpansionState>( return ValueListenableBuilder<PlayerExpansionState>(
@@ -643,7 +654,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
left: 24, left: 24,
right: 24, right: 24,
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap // Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32, // Use viewPadding to match BottomNavigationBar's height calculation
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).viewPadding.bottom + 32,
child: FadeTransition( child: FadeTransition(
opacity: CurvedAnimation( opacity: CurvedAnimation(
parent: _welcomeFadeController, parent: _welcomeFadeController,
@@ -684,7 +696,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
PlayerRevealOverlay( PlayerRevealOverlay(
key: _revealKey, key: _revealKey,
onDismiss: _hidePlayerReveal, onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12, // Use viewPadding to match BottomNavigationBar's height calculation
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
miniPlayerHeight: 64, miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal, showOnboardingHints: _isOnboardingReveal,
), ),
+158 -10
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart'; import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../services/debug_logger.dart';
import '../../services/settings_service.dart';
final _volumeLogger = DebugLogger();
/// A player card that matches the mini player's visual style. /// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button. /// Used in the player reveal overlay when swiping down or tapping the device button.
@@ -23,10 +28,16 @@ class PlayerCard extends StatefulWidget {
final VoidCallback? onSkipNext; final VoidCallback? onSkipNext;
final VoidCallback? onPower; final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange; final ValueChanged<double>? onVolumeChange;
final ValueChanged<bool>? onPrecisionModeChanged;
// Pastel yellow for grouped players // Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D); static const Color groupBorderColor = Color(0xFFFFF59D);
// Precision mode settings
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
const PlayerCard({ const PlayerCard({
super.key, super.key,
required this.player, required this.player,
@@ -43,6 +54,7 @@ class PlayerCard extends StatefulWidget {
this.onSkipNext, this.onSkipNext,
this.onPower, this.onPower,
this.onVolumeChange, this.onVolumeChange,
this.onPrecisionModeChanged,
}); });
@override @override
@@ -54,7 +66,69 @@ class _PlayerCardState extends State<PlayerCard> {
double _dragVolumeLevel = 0.0; double _dragVolumeLevel = 0.0;
double _cardWidth = 0.0; double _cardWidth = 0.0;
int _lastVolumeUpdateTime = 0; int _lastVolumeUpdateTime = 0;
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
bool _precisionModeEnabled = true; // From settings
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
double _precisionStartX = 0.0; // Finger X position when precision mode started
@override
void initState() {
super.initState();
_loadPrecisionModeSetting();
}
Future<void> _loadPrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = enabled;
});
}
}
@override
void dispose() {
_precisionTimer?.cancel();
super.dispose();
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
_precisionStartX = _lastLocalX; // Capture finger position at entry
});
widget.onPrecisionModeChanged?.call(true);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
context: 'Volume',
);
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (!_inPrecisionMode) return;
setState(() {
_inPrecisionMode = false;
});
widget.onPrecisionModeChanged?.call(false);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: EXITED',
context: 'Volume',
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -67,7 +141,10 @@ class _PlayerCardState extends State<PlayerCard> {
final filledColor = widget.backgroundColor; final filledColor = widget.backgroundColor;
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!; final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
return LayoutBuilder( // RepaintBoundary isolates this card's repaint from parent transform animations
// This improves performance during the staggered reveal animation
return RepaintBoundary(
child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_cardWidth = constraints.maxWidth; _cardWidth = constraints.maxWidth;
@@ -133,6 +210,7 @@ class _PlayerCardState extends State<PlayerCard> {
), ),
); );
}, },
),
); );
} }
@@ -282,25 +360,83 @@ class _PlayerCardState extends State<PlayerCard> {
} }
void _onDragStart(DragStartDetails details) { void _onDragStart(DragStartDetails details) {
// Get current volume from player // For consecutive swipes, use local volume (API may not have updated player state yet)
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0; final now = DateTime.now().millisecondsSinceEpoch;
final timeSinceLastDrag = now - _lastDragEndTime;
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
// Use local volume if:
// 1. We have a local override (we've swiped before) AND
// 2. We're within the consecutive swipe window (5 seconds)
// Otherwise, read fresh from player state
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
final startVolume = useLocalVolume
? _dragVolumeLevel // Continue from where last swipe ended
: playerVolume; // Fresh from player
_volumeLogger.debug(
'DRAG_START [${widget.player.name}]: '
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
'localVol=${(_dragVolumeLevel * 100).round()}%, '
'playerVol=${(playerVolume * 100).round()}%, '
'startVol=${(startVolume * 100).round()}%',
context: 'Volume',
);
setState(() { setState(() {
_isDraggingVolume = true; _isDraggingVolume = true;
_dragVolumeLevel = currentVolume; _dragVolumeLevel = startVolume;
}); });
_lastDragPosition = details.globalPosition;
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
} }
void _onDragUpdate(DragUpdateDetails details) { void _onDragUpdate(DragUpdateDetails details) {
if (!_isDraggingVolume || _cardWidth <= 0) return; if (!_isDraggingVolume || _cardWidth <= 0) return;
// Directional volume: drag right = increase, drag left = decrease // Track local X position for precision mode
// Distance determines amount of change (full card width = 100%) _lastLocalX = details.localPosition.dx;
// Multiple swipes accumulate - each starts from current volume
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0); // Check for stillness to trigger precision mode (only if enabled in settings)
final currentPosition = details.globalPosition;
if (_precisionModeEnabled && _lastDragPosition != null) {
final movement = (currentPosition - _lastDragPosition!).distance;
if (movement < PlayerCard.precisionStillnessThreshold) {
// Finger is still - start precision timer if not already running
if (_precisionTimer == null && !_inPrecisionMode) {
_precisionTimer = Timer(
Duration(milliseconds: PlayerCard.precisionTriggerMs),
_enterPrecisionMode,
);
}
} else {
// Finger moved - cancel timer (but don't exit precision mode if already in it)
_precisionTimer?.cancel();
_precisionTimer = null;
}
}
_lastDragPosition = currentPosition;
double newVolume;
if (_inPrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
// Full card width of movement = precisionSensitivity (10%) change
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
final offsetX = details.localPosition.dx - _precisionStartX;
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Delta-based movement (full card width = 100%)
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
}
// Always update visual // Always update visual
if ((newVolume - _dragVolumeLevel).abs() > 0.001) { if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
@@ -319,7 +455,17 @@ class _PlayerCardState extends State<PlayerCard> {
void _onDragEnd(DragEndDetails details) { void _onDragEnd(DragEndDetails details) {
// Send final volume on release // Send final volume on release
_volumeLogger.debug(
'DRAG_END [${widget.player.name}]: '
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
'precisionMode=$_inPrecisionMode',
context: 'Volume',
);
widget.onVolumeChange?.call(_dragVolumeLevel); widget.onVolumeChange?.call(_dragVolumeLevel);
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
_exitPrecisionMode();
_lastDragPosition = null;
setState(() { setState(() {
_isDraggingVolume = false; _isDraggingVolume = false;
}); });
@@ -327,6 +473,8 @@ class _PlayerCardState extends State<PlayerCard> {
} }
void _onDragCancel() { void _onDragCancel() {
_exitPrecisionMode();
_lastDragPosition = null;
setState(() { setState(() {
_isDraggingVolume = false; _isDraggingVolume = false;
}); });
@@ -449,6 +449,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Use the elastic animation value directly (already has bounce) // Use the elastic animation value directly (already has bounce)
final slideOffset = distanceToTravel * (1.0 - t); final slideOffset = distanceToTravel * (1.0 - t);
// Darken cards when:
return Transform.translate( return Transform.translate(
offset: Offset(0, slideOffset), offset: Offset(0, slideOffset),
child: Padding( child: Padding(
+300 -121
View File
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:great_list_view/great_list_view.dart';
import '../../providers/music_assistant_provider.dart'; import '../../providers/music_assistant_provider.dart';
import '../../models/player.dart'; import '../../models/player.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../common/empty_state.dart'; import '../common/empty_state.dart';
/// Panel that displays the current playback queue /// Panel that displays the current playback queue with drag-to-reorder
class QueuePanel extends StatelessWidget { /// and swipe-left-to-delete functionality
class QueuePanel extends StatefulWidget {
final MusicAssistantProvider maProvider; final MusicAssistantProvider maProvider;
final PlayerQueue? queue; final PlayerQueue? queue;
final bool isLoading; final bool isLoading;
@@ -31,49 +33,145 @@ class QueuePanel extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { State<QueuePanel> createState() => _QueuePanelState();
return Container( }
color: backgroundColor,
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
onPressed: onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: onRefresh,
padding: Spacing.paddingAll12,
),
],
),
),
// Queue content class _QueuePanelState extends State<QueuePanel> {
Expanded( final AnimatedListController _listController = AnimatedListController();
child: isLoading List<QueueItem> _items = [];
? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty // Track pointer for right-swipe-to-close (bypasses gesture arena)
? _buildEmptyState(context) Offset? _pointerStart;
: _buildQueueList(), static const _swipeThreshold = 80.0; // Minimum horizontal distance for swipe
),
], @override
void initState() {
super.initState();
_items = widget.queue?.items ?? [];
}
@override
void didUpdateWidget(QueuePanel oldWidget) {
super.didUpdateWidget(oldWidget);
final newItems = widget.queue?.items ?? [];
// Sync items when:
// 1. Player changed (different queue entirely)
// 2. Item count changed (deletion confirmed by server, or track added)
// 3. Item order changed (shuffle toggle, manual reorder)
final playerChanged = widget.queue?.playerId != oldWidget.queue?.playerId;
final countChanged = newItems.length != _items.length;
final orderChanged = !_itemListsEqual(newItems, _items);
if (playerChanged || countChanged || orderChanged) {
_items = newItems;
}
}
/// Compare two item lists by queueItemId to detect reordering
bool _itemListsEqual(List<QueueItem> a, List<QueueItem> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i].queueItemId != b[i].queueItemId) return false;
}
return true;
}
String _formatDuration(Duration? duration) {
if (duration == null) return '';
final totalSeconds = duration.inSeconds;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
void _handleDelete(QueueItem item) async {
// DON'T update _items - let Dismissible handle the visual removal
// Updating _items causes AutomaticAnimatedListView to also animate,
// creating a double-animation bug
// Just call the API - fire and forget
final playerId = widget.queue?.playerId;
if (playerId != null) {
widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
}
}
void _handleReorder(int oldIndex, int newIndex) async {
// Reorder via API - use playerId as queue ID
final playerId = widget.queue?.playerId;
if (playerId != null && oldIndex != newIndex) {
final item = _items[oldIndex];
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, newIndex);
widget.onRefresh();
}
}
@override
Widget build(BuildContext context) {
// Use Listener for raw pointer events to detect right-swipe-to-close
// This bypasses the gesture arena so Dismissible doesn't block it
return Listener(
onPointerDown: (event) {
_pointerStart = event.position;
},
onPointerMove: (event) {
if (_pointerStart != null) {
final dx = event.position.dx - _pointerStart!.dx;
final dy = (event.position.dy - _pointerStart!.dy).abs();
// Right swipe: horizontal movement > threshold, mostly horizontal (not vertical)
if (dx > _swipeThreshold && dx > dy * 2) {
_pointerStart = null; // Reset to prevent multiple triggers
widget.onClose();
}
}
},
onPointerUp: (_) => _pointerStart = null,
onPointerCancel: (_) => _pointerStart = null,
child: Container(
color: widget.backgroundColor,
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
onPressed: widget.onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: widget.textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.refresh_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: widget.onRefresh,
padding: Spacing.paddingAll12,
),
],
),
),
// Queue content
Expanded(
child: widget.isLoading
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
: widget.queue == null || _items.isEmpty
? _buildEmptyState(context)
: _buildQueueList(),
),
],
),
), ),
); );
} }
@@ -83,96 +181,177 @@ class QueuePanel extends StatelessWidget {
} }
Widget _buildQueueList() { Widget _buildQueueList() {
final currentIndex = queue!.currentIndex ?? 0; final currentIndex = widget.queue!.currentIndex ?? 0;
final items = queue!.items;
return ListView.builder( return AutomaticAnimatedListView<QueueItem>(
list: _items,
padding: Spacing.paddingH8, padding: Spacing.paddingH8,
cacheExtent: 500, comparator: AnimatedListDiffListComparator<QueueItem>(
addAutomaticKeepAlives: false, // Items don't need individual keep-alive sameItem: (a, b) => a.queueItemId == b.queueItemId,
addRepaintBoundaries: false, // We add RepaintBoundary manually sameContent: (a, b) => a.track.name == b.track.name && a.track.duration == b.track.duration,
itemCount: items.length, ),
itemBuilder: (context, index) { itemBuilder: (context, item, data) {
final item = items[index]; final index = _items.indexOf(item);
final isCurrentItem = index == currentIndex; final isCurrentItem = index == currentIndex;
final isPastItem = index < currentIndex; final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
return RepaintBoundary( // Always return same widget structure (with Dismissible) for consistency
child: Opacity( // Only vary the measuring flag for image loading optimization
opacity: isPastItem ? 0.5 : 1.0, return _buildDismissibleQueueItem(item, index, isCurrentItem, isPastItem, measuring: data.measuring);
child: Container( },
margin: EdgeInsets.symmetric(vertical: Spacing.xxs), listController: _listController,
decoration: BoxDecoration( reorderModel: AnimatedListReorderModel(
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent, onReorderStart: (index, dx, dy) => true,
borderRadius: BorderRadius.circular(Radii.md), onReorderFeedback: (index, dropIndex, offset, dx, dy) => null,
), onReorderMove: (index, dropIndex) => true,
child: ListTile( onReorderComplete: (index, dropIndex, slot) {
dense: true, _handleReorder(index, dropIndex);
leading: ClipRRect( return true;
borderRadius: BorderRadius.circular(Radii.sm), },
child: SizedBox( ),
width: 44, addLongPressReorderable: false, // We use custom drag handle instead
height: 44, );
child: imageUrl != null }
? CachedNetworkImage(
imageUrl: imageUrl, Widget _buildDismissibleQueueItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {bool measuring = false}) {
fit: BoxFit.cover, return Dismissible(
// PERF: 4x cache size for 44x44 display, zero fade for scrolling key: ValueKey(item.queueItemId),
memCacheWidth: 176, direction: DismissDirection.endToStart, // Swipe left to delete only
memCacheHeight: 176, background: Container(
fadeInDuration: Duration.zero, alignment: Alignment.centerRight,
fadeOutDuration: Duration.zero, padding: const EdgeInsets.only(right: 20),
placeholder: (context, url) => Container( decoration: BoxDecoration(
color: textColor.withOpacity(0.1), color: Colors.red.shade700,
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20), borderRadius: BorderRadius.zero,
), ),
errorWidget: (context, url, error) => Container( child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
color: textColor.withOpacity(0.1), ),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20), confirmDismiss: (direction) async {
), // Don't allow deleting currently playing track
) return !isCurrentItem;
: Container( },
color: textColor.withOpacity(0.1), onDismissed: (direction) {
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20), _handleDelete(item);
},
child: _buildQueueItem(item, index, isCurrentItem, isPastItem, measuring: measuring),
);
}
Widget _buildQueueItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {bool measuring = false}) {
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
final duration = _formatDuration(item.track.duration);
return RepaintBoundary(
child: Opacity(
opacity: isPastItem ? 0.5 : 1.0,
child: Container(
// Use background color with zero radius to cover any grey from list package
decoration: BoxDecoration(
color: isCurrentItem ? widget.primaryColor.withOpacity(0.15) : widget.backgroundColor,
borderRadius: BorderRadius.zero,
),
child: ListTile(
dense: true,
leading: ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: SizedBox(
width: 44,
height: 44,
child: imageUrl != null && !measuring
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 176,
memCacheHeight: 176,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: widget.textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
), ),
), errorWidget: (context, url, error) => Container(
color: widget.textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
),
)
: Container(
color: widget.textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
),
), ),
title: Text( ),
item.track.name, title: Text(
style: TextStyle( item.track.name,
color: isCurrentItem ? primaryColor : textColor, style: TextStyle(
fontSize: 14, color: isCurrentItem ? widget.primaryColor : widget.textColor,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal, fontSize: 14,
), fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty maxLines: 1,
? Text( overflow: TextOverflow.ellipsis,
item.track.artists!.first.name, ),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
? Text(
item.track.artists!.first.name,
style: TextStyle(
color: widget.textColor.withOpacity(0.6),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (duration.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
duration,
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(0.6), color: widget.textColor.withOpacity(0.5),
fontSize: 12, fontSize: 12,
), ),
maxLines: 1, ),
overflow: TextOverflow.ellipsis, ),
) if (isCurrentItem)
: null, Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20)
trailing: isCurrentItem else if (!measuring)
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20) Builder(
: null, builder: (itemContext) => GestureDetector(
onTap: () { onVerticalDragStart: (details) {
// TODO: Jump to this track in queue _listController.notifyStartReorder(
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint itemContext,
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it details.localPosition.dx,
// Alternative: Could use multiple next() calls but that's inefficient and unreliable details.localPosition.dy,
}, );
},
onVerticalDragUpdate: (details) {
_listController.notifyUpdateReorder(
details.localPosition.dx,
details.localPosition.dy,
);
},
onVerticalDragEnd: (details) {
_listController.notifyStopReorder(false);
},
onVerticalDragCancel: () {
_listController.notifyStopReorder(true);
},
child: Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
),
)
else
Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
],
), ),
onTap: () {
// TODO: Jump to this track in queue if MA API supports it
},
), ),
), ),
); ),
},
); );
} }
} }
+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 name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.8.0-beta+37 version: 2.8.5-beta+43
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -49,6 +49,9 @@ dependencies:
# Raw PCM Audio Playback (for Sendspin streaming) # Raw PCM Audio Playback (for Sendspin streaming)
flutter_pcm_sound: ^3.3.3 flutter_pcm_sound: ^3.3.3
# Animated list with drag-to-reorder
great_list_view: ^0.2.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter