Compare commits

...
103 Commits
Author SHA1 Message Date
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
Your Name 8f64a75206 Bump version to 2.8.0-beta+37 2026-01-04 13:53:48 +00:00
Your NameandClaude Opus 4.5 cbb67eacce Fix artist grid view hero animation
Add missing heroTag to ArtistAvatar in grid view - list view had it but grid view was missing it entirely, causing fade instead of hero animation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:41 +00:00
Your NameandClaude Opus 4.5 74f1b0f16f Persist podcast cover cache for smooth heroes + high-res
- Add SharedPreferences persistence for iTunes podcast cover URLs
- Load cached URLs on app start (before UI renders) for instant high-res
- Save new iTunes URLs as they're fetched for future launches
- Change iTunes artwork from 1400x1400 to 800x800 (still crisp, smaller payload)
- Remove useStableUrl workaround (no longer needed with persistence)

This ensures:
- Smooth hero animations (same URL in library and detail)
- High-res podcast covers (iTunes 800x800)
- Instant display on repeat launches (URLs loaded from storage)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:15:24 +00:00
Your NameandClaude Opus 4.5 9c293a6d64 Fix podcast hero: use stable URLs to prevent mid-session URL changes
ROOT CAUSE IDENTIFIED:
Albums/artists/audiobooks use getImageUrl() which ALWAYS returns the same URL.
Podcasts use getPodcastImageUrl() which can return DIFFERENT URLs:
- Before iTunes lookup: imageproxy URL
- After iTunes lookup: iTunes 1400px URL

This causes hero animation failures because:
1. Library displays imageproxy URL image
2. iTunes lookup completes asynchronously
3. User taps - getPodcastImageUrl now returns iTunes URL
4. Detail screen gets different URL than library is showing
5. Cache miss during hero animation = choppy

FIX:
- Added useStableUrl parameter to getPodcastImageUrl()
- When true, bypasses iTunes cache and always returns imageproxy URL
- Library now uses useStableUrl: true for all podcast images
- Precaching also uses useStableUrl: true
- This ensures SAME URL in library, precache, and detail screen

Also removed memCacheWidth from library (decode at native resolution).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:41:24 +00:00
Your NameandClaude Opus 4.5 4cba2ba889 Fix podcast hero: ensure all views have identical widget structure
Critical fix: List view had different structure from grid/detail:
- List: ClipRRect(8) → CachedNetworkImage (no Container wrapper)
- Grid: ClipRRect(16) → Container → CachedNetworkImage
- Detail: ClipRRect(16) → Container → CachedNetworkImage

Hero animations require IDENTICAL widget structures to morph smoothly.

Now all three match:
- borderRadius: 16 (was 8 in list view)
- Container wrapper with surfaceContainerHighest color
- CachedNetworkImage with fadeIn/fadeOut Duration.zero

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:37:40 +00:00
Your NameandClaude Opus 4.5 3b4d1add01 Fix podcast hero animations - match audiobook pattern exactly
ROOT CAUSE FOUND:
1. URL mismatch: getPodcastImageUrl() returns different URLs at different
   times (imageproxy vs iTunes) causing cache misses during hero animation
2. Widget structure mismatch: Library used ClipRRect but detail used
   BoxDecoration, causing hero to morph between incompatible widgets

FIXES APPLIED:

1. Use initialImageUrl FIRST (not as fallback):
   - Library passes exact URL it uses for display
   - Detail screen now uses that same URL for hero animation
   - Prevents iTunes cache from changing URL mid-flight

2. Match audiobook widget structure exactly:
   - Changed from: Container + BoxDecoration + DecorationImage
   - Changed to: ClipRRect + Container + CachedNetworkImage
   - Same structure as library = smooth hero morphing

3. Added proper CachedNetworkImage properties:
   - fadeInDuration: Duration.zero
   - fadeOutDuration: Duration.zero
   - placeholder and errorWidget handlers

Widget structure comparison (now matching):
  Library:  Hero → ClipRRect(16) → Container → CachedNetworkImage
  Detail:   Hero → ClipRRect(16) → Container → CachedNetworkImage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:24:20 +00:00
Your NameandClaude Opus 4.5 5a0b3e4f61 Fix podcast hero animations and further improve search ranking
Hero Animation Fixes (matching album pattern exactly):
- Increase podcast detail cover size from 0.6/280px to 0.7/320px (matches albums)
- Use DecorationImage with CachedNetworkImageProvider instead of CachedNetworkImage
  widget (matches album detail screen exactly)
- Remove memCacheWidth/Height constraints for full resolution display
- Add box shadow to match album detail screen aesthetic
- Update border radius to 16px in both library grid and detail screen

Search Ranking Improvements:
- Calculate "prominence" of query in podcast name (query length / name length)
- Higher boost (+15) when query is >50% of podcast name
- Medium boost (+12) when query is 30-50% of name
- Base boost (+8) for other matches

Example: "Louis Theroux Interviews" with query "Louis Theroux"
- Query is 14/23 = 60% of name -> +15 boost
- Combined with word boundary match (60) = 75 points total
- Should rank above tracks with artist match (55 points max)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:05:23 +00:00
Your NameandClaude Opus 4.5 081afb3948 Fix podcast hero animations and search relevance
Hero Animation Fixes:
- Change getPodcastImageUrl default size from 1024px to 256px to match
  albums/artists and ensure cache consistency for smooth hero transitions
- Add podcast image pre-caching in library screen (matches album pattern)

Search Relevance Fixes:
- Fix _matchesWordBoundary() to handle multi-word queries like "Louis Theroux"
- Add podcast metadata checks for owner/creator fields (not just author/publisher)
- Increase podcast description bonus from +3 to +5 (matches other media types)
- Add fallback boost (+10) for podcasts when multi-word query matches in name
  but no creator metadata exists (podcast names often include host name)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:43:48 +00:00
Your NameandClaude Opus 4.5 3a668348a2 Improve hero animations and search relevance scoring
Hero Animation Fixes:
- Align memCacheWidth/Height to 256 across library, search, and detail screens
- Ensures same cached image is used for hero source and destination

Search Relevance Improvements:
- Add author/narrator scoring for Audiobooks (+15 exact, +8 contains)
- Add author/publisher/description scoring for Podcasts
- Podcasts matching creator name now rank higher (fixes Louis Theroux issue)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:20:45 +00:00
Your NameandClaude Opus 4.5 5e0277ad9f Fix type pills and hero animation improvements
- Add podcastSingular localization key
- Use singular labels in type pills (Podcast not Podcasts)
- Fix pill stretching for artist/radio/podcast with mainAxisSize.min
- Increase pill size (8x3 padding, 11px font)
- Align memCacheWidth/Height to 400 across library and detail screens
  for smoother hero animations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:02:49 +00:00
Your NameandClaude Opus 4.5 90805c522a Fix: use 'podcasts' localization key instead of 'podcast'
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:46:40 +00:00
Your NameandClaude Opus 4.5 1b2fedb544 Add podcasts to search results with colored type pills
- Add searchPodcasts API method and include podcasts in search results
- Add podcast tile with navigation to podcast detail screen
- Add colored type pills to distinguish media types in "All" view:
  - Track (blue), Album (purple), Artist (pink), Playlist (teal)
  - Audiobook (orange), Radio (red), Podcast (green)
- Fix choppy hero animations for podcast covers by adding memCacheWidth/memCacheHeight constraints

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:35:40 +00:00
Your NameandClaude Opus 4.5 7deb5b7fda Podcast improvements with iTunes high-res artwork
Episode list redesign:
- 72x72 episode covers (each can have unique artwork)
- Duration + release date on same line
- Long-press to expand: full description + action buttons
- Action buttons match album track style (44x44 icon-only)

Podcast cover quality fix:
- Fetch high-res artwork from iTunes API (1400x1400)
- Works for any podcast listed in iTunes (most podcasts)
- Correct podcast artwork, not episode covers
- Cached in background after podcast list loads

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:16:06 +00:00
Your Name 7cdf303116 Fix Track import - it's in media_item.dart not separate file 2026-01-04 09:44:46 +00:00
Your NameandClaude Opus 4.5 c898ca51a9 Smart podcast cover selection based on image source comparison
Compare podcast and episode image URLs to determine if they're the
same source image. If same source, use episode version for higher
quality. If different sources (unique episode covers), keep the
podcast's own cover to avoid incorrect artwork.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:44:03 +00:00
Your NameandClaude Opus 4.5 22b3526497 Only use episode cover as fallback when podcast has no cover
Previously we always overwrote podcast covers with episode covers,
which was wrong for podcasts where each episode has a unique cover.

Now we check if the podcast has its own image first and only fall
back to episode covers when the podcast itself has no cover image.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:41:58 +00:00
Your NameandClaude Opus 4.5 eea675ba53 Redesign podcast episode list with covers, dates, and expandable actions
- Replace play icon with actual episode covers (72x72, rounded)
- Each episode can have its own unique cover art
- Add release date display alongside episode duration
- Long-press to expand episode showing full description
- Expanded state reveals action buttons: Play, Add to Queue, Play On...
- Add debug logging for episode metadata to discover date field names

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:36:07 +00:00
Your NameandClaude Opus 4.5 e74f59e931 Use high-quality episode covers on podcast detail screen
Updated podcast_detail_screen.dart to use getPodcastImageUrl()
for both the cover display and color extraction.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:49:33 +00:00
Your NameandClaude Opus 4.5 8584353d65 Use episode covers for higher quality podcast library images
Music Assistant sometimes returns low-res images for podcasts. Added:
- Podcast cover cache populated with episode covers in background
- getPodcastImageUrl() method that checks cache first
- Library screen now uses getPodcastImageUrl for podcasts

Covers are loaded progressively after initial podcast list loads,
so the UI is not blocked while fetching episode data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:37:59 +00:00
Your NameandClaude Opus 4.5 0495e0c418 Fix podcast name detection for episodes with split titles
When podcast episode titles contain " - " (like "Show 73 - Mania for
Subjugation III"), the title was being split and "Show 73" became the
artist. The caching logic then kept the "existing" track thinking it
had better data, but this caused the album (podcast name) from the
player_updated event to be lost.

Fix: For podcast URIs, if the new track has album info but existing
doesn't, either prefer the new track or merge the album data into
the existing track. Also update _currentTrack when we have better
album data for podcasts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:34:26 +00:00
Your NameandClaude Opus 4.5 87a0b8c04c Add podcast field extraction and detailed logging
- Log all currentMedia fields for podcast episodes
- Extract and store podcast field in metadata
- Add helper method for episode cover fallback
- Helps diagnose podcast name detection issues

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:13:13 +00:00
Your NameandClaude Opus 4.5 ef384fa41f Fix duplicate podcast name and add debug logging
- Hide album line for podcasts (was duplicating podcast name)
- Add debug logging to understand podcast data structure
- Helps diagnose why some podcasts show "Podcasts" fallback

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:07:51 +00:00
Your NameandClaude Opus 4.5 b2d39404b6 Fix localization: use podcasts instead of podcast
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:50:15 +00:00
Your NameandClaude Opus 4.5 c655f79b71 Add podcast skip controls and fix artist display in expanded player
- Add isPlayingPodcast getter to detect podcast episodes by URI pattern
- Add currentPodcastName getter to show podcast name instead of artist
- Use audiobook-style skip 30s controls for podcasts in expanded player
- Display podcast name in artist line instead of "Unknown Artist"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:41:11 +00:00
Your NameandClaude Opus 4.5 0bec855d7b Use correct endpoint: music/podcasts/podcast_episodes
Found the actual API endpoint from Music Assistant documentation.
Simplified the code to use the proper endpoint.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:19:09 +00:00
Your NameandClaude Opus 4.5 752dd9ac26 Try multiple approaches for podcast episodes like audiobooks
- Check metadata for episodes array
- Use provider_mappings to fetch from source provider (Spotify)
- Log total_episodes and metadata keys for debugging

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:15:57 +00:00
Your NameandClaude Opus 4.5 aa6d616741 Use podcast's actual URI for browsing episodes
- Use the podcast's uri from API response instead of constructed path
- Filter out folder navigation items (like "..")
- Add _parseBrowseEpisodes helper method
- Include duration parsing for episodes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:13:32 +00:00
Your NameandClaude Opus 4.5 8476798093 Fix MediaType parsing in podcast episode browse
- Parse media_type string to MediaType enum properly
- Default to MediaType.track if parsing fails

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:57:03 +00:00
Your NameandClaude Opus 4.5 a2327e5183 Add detailed logging for podcast episode parsing
- Log browse item structure (keys, name, label, uri, path, media_type)
- Parse browse items with fallback for name/label fields
- Add URI logging to play method for debugging
- Better handling of browse response format

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:47:39 +00:00
Your NameandClaude Opus 4.5 0fad37147f Fix podcast episodes and image resolution
Episodes:
- Add multiple fallback methods for fetching podcast episodes
- Try music/podcasts/get with various episode keys
- Try browse endpoint with podcast URI
- Try podcast_episodes library endpoint
- Add comprehensive debug logging

Images:
- Increase podcast image size from 512 to 1024
- Increase memory cache size for better quality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:34:21 +00:00
Your NameandClaude Opus 4.5 3667df5b5c Fix podcast detail screen compilation errors
- Use CachedNetworkImageProvider for color extraction
- Change _formatDuration to accept Duration instead of int
- Replace non-existent podcastOff icon with microphoneOff
- Fix duration comparison to use Duration.zero

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:14:24 +00:00
Your NameandClaude Opus 4.5 4521318686 Add podcast details screen with episodes list
- Create PodcastDetailScreen with podcast cover, title, author
- Load and display episode list with play functionality
- Add hero animations between library and details screens
- Add expandable description section
- Add podcastCover hero tag constant
- Pass imageUrl parameter to navigation for adaptive colors

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:03:32 +00:00
Your NameandClaude Opus 4.5 29d14d9c13 Fix null safety for S.of(context) in podcast details
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:40:46 +00:00
Your NameandClaude Opus 4.5 250d79bd39 Fix AppLocalizations reference in podcast details
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:31:41 +00:00
Your NameandClaude Opus 4.5 c1837f5227 Add basic podcast support
- Add getPodcasts, getPodcastEpisodes, and playPodcastEpisode API methods
- Add podcast state (podcasts list, isLoadingPodcasts) to provider
- Replace "Coming soon" placeholder with functional podcasts tab
- Add list and grid view modes for podcasts (2-column and 3-column)
- Add view mode toggle support for podcasts
- Persist podcast view mode preference in settings
- Add localization strings for podcasts (noPodcasts, addPodcastsHint, episodes)
- Tapping a podcast shows placeholder message (details screen coming later)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:21:54 +00:00
Your NameandClaude Opus 4.5 a59946cc69 Fix radio stations layout and add grid view support
- Add proper spacing between radio station items in list view
- Add grid view support (2-column and 3-column layouts)
- Add view mode toggle button functionality for radio tab
- Persist radio view mode preference in settings
- Add pull-to-refresh support
- Optimize image caching based on view mode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:15:04 +00:00
Your NameandClaude Opus 4.5 6e05a948f1 Add global radio station search via Music Assistant API
- Add searchRadioStations() method that searches with media_types=['radio']
- Combine library radio filtering with global search results
- Deduplicate results by name, preferring library items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:52:55 +00:00
Your NameandClaude Opus 4.5 80a7848204 Filter radio stations from loaded list during search
The Music Assistant search API doesn't return radios, so filter
from the provider's pre-loaded radioStations list instead.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:31:26 +00:00
Your NameandClaude Opus 4.5 0d09fb3625 Add radio stations to search results
- Parse radios from search API response
- Add radio filter tab to search screen
- Add radio tile with play functionality
- Add playingRadioStation localization

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:14:35 +00:00
Your NameandClaude Opus 4.5 cff1edcc02 Optimize list scroll performance
- Add 200ms delay before fetching fallback images to avoid
  network requests during fast scrolling
- Use dynamic image cache sizes based on view mode:
  - List view: 128px (smaller thumbnails)
  - 3-col grid: 200px
  - 2-col grid: 300px
- Increase ListView cacheExtent from 500px to 1000px to
  pre-build more offscreen items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:53:59 +00:00
Your NameandClaude Opus 4.5 300f92a327 Add fast settling physics for horizontal page swipes
Adds _FastPageScrollPhysics to both library and search screens
to reduce the time PageView animations take to settle. This makes
vertical scrolling within tabs more responsive after swiping
horizontally between pages.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:41:55 +00:00
Your NameandClaude Opus 4.5 c600029202 Use PageView.builder to only build visible tabs
Previously all 3-4 tabs were built upfront with PageView(children:).
Now only the current tab is built, with adjacent tabs for smooth swiping.

This should significantly improve tab swipe performance.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:01:09 +00:00
Your NameandClaude Opus 4.5 c703eef343 Fix RestorableInt/ValueListenable type mismatch
RestorableInt doesn't implement ValueListenable, so add separate
ValueNotifier for UI updates while keeping RestorableInt for state
restoration.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:43:45 +00:00
Your NameandClaude Opus 4.5 48ffe0c826 Debounce color extraction to avoid blocking UI during scroll
- Queue color extraction requests with 300ms debounce
- Process extractions in batch after scroll settles
- Add 50ms yield between extractions for UI responsiveness
- Add mounted checks to prevent work after disposal

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:33:34 +00:00
Your NameandClaude Opus 4.5 8986c697c3 perf: throttle letter scrollbar updates to reduce rebuilds
Only update scroll fraction when it changes by more than 1%, reducing
the number of setState calls and widget rebuilds during scrolling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:29:19 +00:00
Your NameandClaude Opus 4.5 948599bf3e perf: pre-sort lists on load instead of every build
- Add cached sorted lists for playlists, tracks, audiobooks, authors, series
- Sort once when data is loaded, not on every widget rebuild
- Pre-group audiobooks by author when loaded
- Re-sort audiobooks when sort order changes (alpha/year)

This eliminates O(n log n) sorting operations during scroll/swipe animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:28:42 +00:00
Your NameandClaude Opus 4.5 11d3dec72d perf: eliminate full screen rebuilds during PageView swipe
- Remove setState from _onPageChanged in both library and search screens
- Use ValueNotifier + ValueListenableBuilder for filter chip updates
- Only filter chips rebuild during swipe, not entire screen
- Remove redundant _selectedCategoryIndex in library screen

This significantly reduces widget rebuilds during swipe animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:23:26 +00:00
Your NameandClaude Opus 4.5 d85407bee2 style: add Spotify-style fade gradient under filter bars
Content now fades out as it scrolls under the filter bar instead of
abruptly meeting it. Uses a 24px gradient from background color to
transparent on both library and search screens.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:55:53 +00:00
Your NameandClaude Opus 4.5 891c8948e0 fix: smooth letter scrollbar thumb motion
Use AnimatedPositioned with 50ms duration to smooth out the thumb
tracking during scroll, eliminating jerky/stuttery motion.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:30:55 +00:00
Your NameandClaude Opus 4.5 b7da11847a style: add visual gap under filter bars when content scrolls
Increased top padding on list/grid views in library and search screens
so there's a visible gap when content scrolls under the tab bars.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:25:47 +00:00
Your NameandClaude Opus 4.5 6eef5ff9dc fix: letter scrollbar thumb tracking and grab area
- Thumb now tracks scroll position instead of staying centered
- Added onDragStateChanged callback to prevent tab bar hiding during drag
- Made grab area wider (32px) for easier interaction
- Adjusted letter popup position for wider grab area

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:15:54 +00:00
Your Name 7405f0728e style: increase gap between library type and filter bars 2026-01-03 17:02:03 +00:00
Your NameandClaude Opus 4.5 317ec426be fix: filter tab vertical alignment and scroll-to-hide behavior
- Fix vertical alignment of text in filter tab row (center aligned)
- Only hide library type bar when scrolling, keep filter tab always visible
- Use Container with Alignment.center for proper text centering

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:01:37 +00:00
Your NameandClaude Opus 4.5 9902a9f0c5 feat: add letter scrollbar for fast list navigation
- Create LetterScrollbar widget that shows a letter popup when dragging
- Apply to library screen lists: Artists, Albums, Playlists, Authors, Audiobooks, Series
- Dragging the scroll thumb shows the current letter in a popup bubble
- Jump to items starting with that letter for quick navigation
- Closes #36

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:53:23 +00:00
Your NameandClaude Opus 4.5 5f6451153a refactor: improve filter bar spacing and add scroll-to-hide
Library screen:
- Reduce filter row height from 44px to 36px
- Add 8px spacing between library type and filter rows
- Remove bottom border line from filter bar
- Add scroll-to-hide behavior (like search screen)

Search screen:
- Reduce filter bar height from 44px to 36px
- Adjust padding to match smaller height

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:47:02 +00:00
Your NameandClaude Opus 4.5 fdefa32bef refactor: improve filter bar layout and consistency
Library screen:
- Justify media type chips across full width (spaceBetween)
- Reduce vertical spacing between rows (fixed 44px height)
- Removed redundant padding

Search screen:
- Filter bar now matches search bar width with rounded corners (12px)
- Content clips and scrolls within the rounded container
- Same 44px height as library screen for consistency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:29:47 +00:00
Your NameandClaude Opus 4.5 00d1763c99 refactor: improve library filter layout per feedback
- Remove full-width background from media type row
- Show icons only when media type is selected (saves horizontal space)
- Use tertiaryContainer for selected state (complementary color)
- Move action buttons (favorites, view mode) inline with sub-category row
- Revert search screen to inline library toggle in search bar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:08:40 +00:00
Your NameandClaude Opus 4.5 3e1c2f9da1 refactor: two-row filter layout with floating FABs
- Library screen: replace dropdown with two-row chip layout
  - Row 1: Media type chips (Music, Audiobooks, Podcasts, Radio) with secondaryContainer background and icons
  - Row 2: Sub-category chips (Artists, Albums, Playlists, etc.)
- Move favorites and view mode toggles to floating FABs in bottom-right above mini player
- Search screen: move library-only toggle from search bar to floating FAB
- Remove old dropdown-based media type selector

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 15:46:44 +00:00
Your NameandClaude Opus 4.5 2a583d28ef Fix build errors in library redesign
- Replace _tabController.index with _selectedCategoryIndex
- Fix metadata access to use Map syntax (metadata?['description'])
- Add playRadioStation method to API
- Use maProvider.api?.playRadioStation instead of undefined playMedia

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:57:30 +00:00
Your NameandClaude Opus 4.5 009af477ad Redesign library screen with dropdown + chips layout and add radio support
- Replace media type icon buttons with dropdown selector
- Replace TabBar with horizontal scrollable chips (matching search screen)
- Remove PlayerSelector from library AppBar (redundant with mini player)
- Add Radio as a new library media type
- Add getRadioStations API endpoint
- Add loadRadioStations to provider
- Add radio stations tab with list view
- Add localization strings for radio, stations, selectLibrary

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:45:05 +00:00
Your Name 91def41c9f Raise secondary line in 3-line mini player by 1px 2026-01-02 09:45:23 +00:00
Your Name ede34224f4 Fix 3-line mini player: restore 16px font, raise primary line 2026-01-02 09:26:11 +00:00
Your Name 676848779f Center power button vertically 2026-01-02 09:20:21 +00:00
Your NameandClaude Opus 4.5 c7dd415169 Fix power button style, reduce album gap, adjust mini player spacing
- Power button now matches PlayerCard style (IconButton, no background)
- Album details: reduce bottom padding from 24px to 12px
- Mini player: raise secondary line from y=42 to y=40

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 09:06:52 +00:00
Your NameandClaude Opus 4.5 b0baaecb1f Add power button and improve mini player 2-line layout
- Add power toggle button on right side (2-line off state only)
- Increase primary font size from 16px to 18px in 2-line mode
- Evenly space the two text lines vertically (14px and 42px)
- Power button shows powered state with opacity change

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:50:10 +00:00
Your NameandClaude Opus 4.5 30a4a432f1 Fix row height clipping and increase detail screen artwork
- Add 8px total to row height formula for 4th row behind mini player
- Add clipBehavior: Clip.none to album row for descender overflow
- Add 4px bottom padding to album cards for descender room (y, g, j, q)
- Tighten artist card line height to 1.15 for 2-line names
- Increase detail screen artwork from 40-50% to 70% on all screens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:48:11 +00:00
38 changed files with 7332 additions and 1066 deletions
+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`
+3
View File
@@ -25,4 +25,7 @@ class HeroTags {
/// Audiobook author image hero tag prefix
static const String authorImage = 'author_image_';
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
}
+6
View File
@@ -68,6 +68,12 @@
"tracks": "Songs",
"playlists": "Wiedergabelisten",
"audiobooks": "Hörbücher",
"music": "Musik",
"radio": "Radio",
"stations": "Sender",
"selectLibrary": "Bibliothek auswählen",
"noRadioStations": "Keine Radiosender",
"addRadioStationsHint": "Füge Radiosender in Music Assistant hinzu",
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
"queue": "Warteschlange",
+72
View File
@@ -316,6 +316,36 @@
"description": "Audiobooks category"
},
"music": "Music",
"@music": {
"description": "Music category"
},
"radio": "Radio",
"@radio": {
"description": "Radio category"
},
"stations": "Stations",
"@stations": {
"description": "Radio stations subcategory"
},
"selectLibrary": "Select Library",
"@selectLibrary": {
"description": "Title for library type selection bottom sheet"
},
"noRadioStations": "No radio stations",
"@noRadioStations": {
"description": "Empty state when no radio stations are available"
},
"addRadioStationsHint": "Add radio stations in Music Assistant",
"@addRadioStationsHint": {
"description": "Hint for adding radio stations"
},
"searchFailed": "Search failed. Please check your connection.",
"@searchFailed": {
"description": "Search error message"
@@ -630,6 +660,16 @@
"description": "Radio button label"
},
"playingRadioStation": "Playing {name}",
"@playingRadioStation": {
"description": "Status when playing radio station",
"placeholders": {
"name": {
"type": "String"
}
}
},
"addedToQueue": "Added to queue",
"@addedToQueue": {
"description": "Confirmation when item added to queue"
@@ -1026,6 +1066,9 @@
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library",
"unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all",
@@ -1036,6 +1079,10 @@
"shows": "Shows",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Podcast support coming soon",
"noPodcasts": "No podcasts",
"addPodcastsHint": "Subscribe to podcasts in Music Assistant",
"episodes": "Episodes",
"episode": "Episode",
"playlist": "Playlist",
"connectionError": "Connection Error",
"twoColumnGrid": "2-column grid",
@@ -1168,6 +1215,11 @@
"description": "Singular form of track for search type indicator"
},
"podcastSingular": "Podcast",
"@podcastSingular": {
"description": "Singular form of podcast for search type indicator"
},
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
"@trackCount": {
"description": "Track count with plural",
@@ -1306,5 +1358,25 @@
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
"@dismissPlayerHint": {
"description": "Hint explaining how to dismiss the player selector"
},
"playingAlbum": "Playing {albumName}",
"@playingAlbum": {
"description": "Message shown when starting to play an album",
"placeholders": {
"albumName": {
"type": "String"
}
}
},
"playingPlaylist": "Playing {playlistName}",
"@playingPlaylist": {
"description": "Message shown when starting to play a playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
}
}
+6
View File
@@ -66,6 +66,12 @@
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"music": "Música",
"radio": "Radio",
"stations": "Emisoras",
"selectLibrary": "Seleccionar Biblioteca",
"noRadioStations": "No hay emisoras de radio",
"addRadioStationsHint": "Añade emisoras de radio en Music Assistant",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
+10 -1
View File
@@ -4,6 +4,7 @@ import '../services/music_assistant_api.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/cache_service.dart';
import '../services/settings_service.dart';
import '../constants/timings.dart';
/// Provider for managing library data (artists, albums, tracks, playlists)
@@ -138,8 +139,12 @@ class LibraryProvider with ChangeNotifier {
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]);
@@ -166,10 +171,14 @@ class LibraryProvider with ChangeNotifier {
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: showOnlyArtistsWithAlbums,
);
_isLoading = false;
File diff suppressed because it is too large Load Diff
+173 -34
View File
@@ -36,6 +36,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
@@ -52,6 +53,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
_loadTracks();
_loadAlbumDescription();
@@ -140,7 +142,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => widget.album.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -171,7 +174,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
throw Exception('Could not determine library ID for this album');
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
@@ -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 {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -260,7 +360,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => track.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -706,8 +807,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
@@ -731,35 +832,50 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
const SizedBox(height: 60),
GestureDetector(
onTap: () => _showFullscreenArt(imageUrl),
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
// Shadow container (outside Hero for correct clipping)
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
)
: null,
),
),
),
@@ -769,7 +885,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -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),
+187 -4
View File
@@ -36,11 +36,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _providerAlbums = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
String? _artistDescription;
String? _artistImageUrl;
MusicAssistantProvider? _maProvider;
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
@@ -52,6 +54,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void initState() {
super.initState();
_isFavorite = widget.artist.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.artist);
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadViewPreferences();
@@ -66,11 +69,60 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
_loadArtistImage();
// Note: _extractColors is called by _loadArtistImage after image loads
// CRITICAL FIX: Delay adding provider listener until AFTER Hero animation
// Adding it immediately causes rebuilds during animation (jank)
_maProvider = context.read<MusicAssistantProvider>();
_maProvider?.addListener(_onProviderChanged);
}
});
});
}
void _onProviderChanged() {
if (!mounted) return;
// Re-check library status when provider data changes
final newIsInLibrary = _checkIfInLibraryFromProvider();
if (newIsInLibrary != _isInLibrary) {
setState(() {
_isInLibrary = newIsInLibrary;
});
}
}
/// Check if artist is in library using provider's artists list
bool _checkIfInLibraryFromProvider() {
if (_maProvider == null) return _checkIfInLibrary(widget.artist);
final artistName = widget.artist.name.toLowerCase();
final artistUri = widget.artist.uri;
// Check if this artist exists in the provider's library
return _maProvider!.artists.any((a) {
// Match by URI if available
if (artistUri != null && a.uri == artistUri) return true;
// Match by name as fallback
if (a.name.toLowerCase() == artistName) return true;
// Check provider mappings for matching URIs
if (widget.artist.providerMappings != null) {
for (final mapping in widget.artist.providerMappings!) {
if (a.providerMappings?.any((m) =>
m.providerInstance == mapping.providerInstance &&
m.itemId == mapping.itemId) == true) {
return true;
}
}
}
return false;
});
}
@override
void dispose() {
_maProvider?.removeListener(_onProviderChanged);
super.dispose();
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
final viewMode = await SettingsService.getArtistAlbumsViewMode();
@@ -172,7 +224,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
orElse: () => widget.artist.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -202,7 +255,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
throw Exception('Could not determine library ID for this artist');
}
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
@@ -243,6 +295,110 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
/// Check if artist is in library
bool _checkIfInLibrary(Artist artist) {
if (artist.provider == 'library') return true;
return artist.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.artist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.artist.provider != 'library') {
actualProvider = widget.artist.provider;
actualItemId = widget.artist.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: artist is library-only');
return;
}
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling artist library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
@@ -553,8 +709,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
@@ -587,8 +743,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: coverSize * 0.5,
@@ -712,6 +872,29 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
+7 -4
View File
@@ -140,7 +140,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
orElse: () => widget.audiobook.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -169,7 +170,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
throw Exception('Could not determine library ID for this audiobook');
}
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
@@ -429,8 +429,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
@@ -512,6 +512,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
+2 -2
View File
@@ -277,8 +277,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
+1 -1
View File
@@ -64,7 +64,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary();
},
+1 -1
View File
@@ -66,7 +66,7 @@ class LibraryArtistsScreen extends StatelessWidget {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary();
},
+6 -12
View File
@@ -30,17 +30,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final playlists = await maProvider.api!.getPlaylists(limit: 100);
setState(() {
_playlists = playlists;
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
}
final playlists = await maProvider.getPlaylists(limit: 100);
setState(() {
_playlists = playlists;
_isLoading = false;
});
}
@override
@@ -89,7 +83,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: _loadPlaylists,
child: ListView.builder(
itemCount: _playlists.length,
+2 -2
View File
@@ -194,7 +194,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
RefreshIndicator(
onRefresh: _onRefresh,
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
child: _buildConnectedView(context, maProvider),
),
// Connecting banner overlay (doesn't affect layout)
@@ -246,7 +246,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = availableHeight / 3;
final rowHeight = (availableHeight + 8) / 3;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
File diff suppressed because it is too large Load Diff
+869
View File
@@ -0,0 +1,869 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastDetailScreen({
super.key,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastDetailScreen> createState() => _PodcastDetailScreenState();
}
class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
List<MediaItem> _episodes = [];
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast);
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
_loadEpisodes();
});
}
/// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.podcast.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.podcast.provider != 'library') {
actualProvider = widget.podcast.provider;
actualItemId = widget.podcast.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: podcast is library-only');
return;
}
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling podcast library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
}
} catch (e) {
_logger.log('🎙️ Error extracting colors: $e');
}
}
}
Future<void> _loadEpisodes() async {
if (_isLoadingEpisodes) return;
setState(() {
_isLoadingEpisodes = true;
});
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
// Debug: Log first episode metadata to see available fields
if (episodes.isNotEmpty) {
final first = episodes.first;
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
_logger.log('🎙️ First episode metadata: ${first.metadata}');
}
setState(() {
_episodes = episodes;
});
}
}
} catch (e) {
_logger.log('🎙️ Error loading episodes: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
}
void _playEpisode(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu(context, episode);
}
}
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes} min';
}
/// Format release date from episode metadata
/// Tries various common field names for publication date
String? _formatReleaseDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
// Try various common field names for publication date
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
// Unix timestamp
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
// Format as "Jan 15, 2024"
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('🎙️ Error parsing release date: $e');
}
return null;
}
/// Add episode to queue on current player
Future<void> _addToQueue(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
// Convert MediaItem to Track for queue addition
final track = Track(
itemId: episode.itemId,
provider: episode.provider,
name: episode.name,
uri: episode.uri,
duration: episode.duration,
metadata: episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
// watch() rebuilds on ANY provider change (library updates, etc.) causing animation jank
// select() only rebuilds when the specific podcast image URL changes
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getPodcastImageUrl(widget.podcast),
);
// Use initialImageUrl for seamless hero animation (same URL as source)
final imageUrl = widget.initialImageUrl ?? providerImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.podcast.metadata?['description'] as String?;
final author = widget.podcast.metadata?['author'] as String?;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + _heroTagSuffix,
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Podcast title
Text(
widget.podcast.name,
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
if (author != null) ...[
const SizedBox(height: 4),
Text(
author,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 16),
// Action buttons row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _playEpisode(_episodes.first),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
disabledForegroundColor: colorScheme.onPrimary.withOpacity(0.38),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
// Description
if (description != null && description.isNotEmpty) ...[
GestureDetector(
onTap: () {
setState(() {
_isDescriptionExpanded = !_isDescriptionExpanded;
});
},
child: AnimatedCrossFade(
firstChild: Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
secondChild: Text(
description,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
crossFadeState: _isDescriptionExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
),
const SizedBox(height: 16),
],
// Episodes header
Row(
children: [
Text(
S.of(context)!.episodes,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(width: 8),
if (_episodes.isNotEmpty)
Text(
'(${_episodes.length})',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
),
// Episodes list
if (_isLoadingEpisodes)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: CircularProgressIndicator(color: colorScheme.primary),
),
),
)
else if (_episodes.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
MdiIcons.microphoneOff,
size: 48,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover (bigger than before)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
],
),
),
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
);
},
childCount: _episodes.length,
),
),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
);
},
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -43,6 +43,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _smartSortPlayers = false;
// Hint settings
bool _showHints = true;
// Library settings
bool _showOnlyArtistsWithAlbums = false;
@override
void initState() {
@@ -94,6 +96,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load hint settings
final showHints = await SettingsService.getShowHints();
// Load library settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -111,6 +116,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
});
}
}
@@ -745,6 +751,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Library section
Text(
S.of(context)!.library,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Show only artists with albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hide artists that have no albums in your library',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
),
value: _showOnlyArtistsWithAlbums,
onChanged: (value) async {
setState(() => _showOnlyArtistsWithAlbums = value);
await SettingsService.setShowOnlyArtistsWithAlbums(value);
// Force sync library to apply the new filter at API level
if (mounted) {
context.read<MusicAssistantProvider>().forceLibrarySync();
}
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section
Text(
S.of(context)!.homeScreen,
+2 -1
View File
@@ -100,7 +100,8 @@ class AuthManager {
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
+46 -2
View File
@@ -120,6 +120,22 @@ class CacheService {
_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
// ============================================================================
@@ -145,12 +161,19 @@ class CacheService {
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
}
/// Invalidate album tracks cache
/// Invalidate album tracks cache for a specific album
void invalidateAlbumTracksCache(String albumId) {
_albumTracksCache.remove(albumId);
_albumTracksCacheTime.remove(albumId);
}
/// Invalidate all album tracks caches (call when tracks are added/removed from library)
void invalidateAllAlbumTracksCaches() {
_albumTracksCache.clear();
_albumTracksCacheTime.clear();
_logger.log('🗑️ All album tracks caches invalidated');
}
/// Check if playlist tracks cache is valid
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -172,12 +195,19 @@ class CacheService {
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
}
/// Invalidate playlist tracks cache
/// Invalidate playlist tracks cache for a specific playlist
void invalidatePlaylistTracksCache(String playlistId) {
_playlistTracksCache.remove(playlistId);
_playlistTracksCacheTime.remove(playlistId);
}
/// Invalidate all playlist tracks caches (call when tracks are added/removed from library)
void invalidateAllPlaylistTracksCaches() {
_playlistTracksCache.clear();
_playlistTracksCacheTime.clear();
_logger.log('🗑️ All playlist tracks caches invalidated');
}
/// Check if artist albums cache is valid
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -199,6 +229,13 @@ class CacheService {
_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
// ============================================================================
@@ -224,6 +261,13 @@ class CacheService {
_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
// ============================================================================
+10
View File
@@ -157,6 +157,16 @@ class DatabaseService {
/// Clear all cached data (useful for logout)
Future<void> clearAllCache() => db.clearAllCache();
/// Clear cached items of a specific type (e.g., 'album', 'artist')
/// Used before re-syncing to remove stale items
Future<void> clearCacheForType(String itemType) => db.clearCache(itemType);
/// Mark a specific cached item as deleted
/// Used when removing items from library for immediate database update
Future<void> markCachedItemDeleted(String itemType, String itemId) {
return db.markItemsDeleted(itemType, [itemId]);
}
// ============================================
// Player Cache Convenience Methods
// ============================================
+479 -71
View File
@@ -293,16 +293,10 @@ class MusicAssistantAPI {
// Handle event
final eventType = data['event'] as String?;
if (eventType != null) {
_logger.log('Event received: $eventType');
// Get the object_id (player_id for player events)
final objectId = data['object_id'] as String?;
final eventData = data['data'] as Map<String, dynamic>? ?? {};
// Debug: Log full data for player events
if (eventType == 'player_added' || eventType == 'player_updated' || eventType == 'builtin_player') {
_logger.log('Event data: ${jsonEncode(eventData)} (player_id: $objectId)');
}
// Include object_id in event data so listeners can filter by player
final enrichedData = {
@@ -498,6 +492,227 @@ class MusicAssistantAPI {
}
}
Future<List<MediaItem>> getRadioStations({
int? limit,
int? offset,
bool? favoriteOnly,
}) async {
try {
final response = await _sendCommand(
'music/radios/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
},
);
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting radio stations: $e');
return [];
}
}
/// Search for radio stations globally (across all providers like TuneIn)
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
try {
final response = await _sendCommand(
'music/search',
args: {
'search_query': query,
'media_types': ['radio'],
'limit': limit,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result == null) return [];
// Radio results might be under 'radios' or 'radio' key
final radios = (result['radios'] as List<dynamic>?) ??
(result['radio'] as List<dynamic>?) ??
[];
return radios
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error searching radio stations: $e');
return [];
}
}
// ============ PODCASTS ============
/// Get all podcasts from the library
Future<List<MediaItem>> getPodcasts({
int? limit,
int? offset,
bool? favoriteOnly,
}) async {
try {
final response = await _sendCommand(
'music/podcasts/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
},
);
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting podcasts: $e');
return [];
}
}
/// Get episodes for a specific podcast
Future<List<MediaItem>> getPodcastEpisodes(String podcastId, {
String? provider,
int? limit,
int? offset,
}) async {
try {
final response = await _sendCommand(
'music/podcasts/podcast_episodes',
args: {
'item_id': podcastId,
if (provider != null) 'provider_instance_id_or_domain': provider,
},
);
final episodes = response['result'] as List<dynamic>?;
if (episodes != null && episodes.isNotEmpty) {
return episodes
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (e) {
_logger.log('Error getting podcast episodes: $e');
return [];
}
}
/// Get best available image URL for a podcast
/// Falls back to first episode's image if podcast image is unavailable or low quality
Future<String?> getPodcastCoverUrl(MediaItem podcast, {int size = 1024}) async {
// First try the podcast's own image
final podcastImage = getImageUrl(podcast, size: size);
// Check if podcast has images in metadata
final images = podcast.metadata?['images'] as List<dynamic>?;
if (images != null && images.isNotEmpty) {
// Podcast has images, use them
return podcastImage;
}
// No podcast image, try to get first episode's image
try {
final episodes = await getPodcastEpisodes(
podcast.itemId,
provider: podcast.provider,
);
if (episodes.isNotEmpty) {
final firstEpisode = episodes.first;
final episodeImage = getImageUrl(firstEpisode, size: size);
if (episodeImage != null) {
return episodeImage;
}
}
} catch (e) {
// Fall through to return podcast image
}
// Fall back to podcast image even if it might be low quality
return podcastImage;
}
/// Parse browse results into MediaItem episodes
List<MediaItem> _parseBrowseEpisodes(List<dynamic> items) {
final episodes = <MediaItem>[];
for (final item in items) {
if (item is Map<String, dynamic>) {
final name = item['name'] as String? ??
item['label'] as String? ??
'Episode';
final uri = item['uri'] as String? ?? item['path'] as String?;
final itemId = item['item_id'] as String? ?? uri ?? '';
final provider = item['provider'] as String? ?? 'library';
// Parse media type from string
final mediaTypeStr = item['media_type'] as String?;
MediaType mediaType = MediaType.track;
if (mediaTypeStr != null) {
mediaType = MediaType.values.firstWhere(
(e) => e.name == mediaTypeStr || e.name == mediaTypeStr.toLowerCase(),
orElse: () => MediaType.track,
);
}
// Get duration if available
Duration? duration;
final durationVal = item['duration'];
if (durationVal is int) {
duration = Duration(seconds: durationVal);
} else if (durationVal is double) {
duration = Duration(seconds: durationVal.toInt());
}
episodes.add(MediaItem(
itemId: itemId,
provider: provider,
name: name,
uri: uri,
metadata: item['metadata'] as Map<String, dynamic>?,
mediaType: mediaType,
duration: duration,
));
}
}
return episodes;
}
/// Play a podcast episode
Future<void> playPodcastEpisode(String playerId, MediaItem episode) async {
try {
// Try episode.uri first, then construct from itemId
String uri;
if (episode.uri != null && episode.uri!.isNotEmpty) {
uri = episode.uri!;
} else {
// Construct URI based on media type
uri = 'library://podcast_episode/${episode.itemId}';
}
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing podcast episode: $e');
rethrow;
}
}
Future<List<Audiobook>> getAudiobooks({
int? limit,
int? offset,
@@ -511,7 +726,6 @@ class MusicAssistantAPI {
// If specific libraries are enabled, use browse API for filtering
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
_logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)');
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
@@ -524,8 +738,6 @@ class MusicAssistantAPI {
if (authorId != null) 'author_id': authorId,
};
_logger.log('📚 Calling music/audiobooks/library_items with args: $args');
final response = await _sendCommand(
'music/audiobooks/library_items',
args: args,
@@ -533,65 +745,21 @@ class MusicAssistantAPI {
// Check for error in response
if (response.containsKey('error_code')) {
_logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}');
return [];
}
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('📚 Audiobooks: result is null');
return [];
}
_logger.log('📚 Audiobooks: found ${items.length} items from API');
// Log first item's structure including metadata to find chapters/series
if (items.isNotEmpty) {
final firstItem = items.first as Map<String, dynamic>;
_logger.log('📚 First item keys: ${firstItem.keys.toList()}');
_logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}');
// Log metadata contents - this is where chapters/series info likely lives
if (firstItem.containsKey('metadata')) {
final metadata = firstItem['metadata'] as Map<String, dynamic>?;
if (metadata != null) {
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
for (final key in metadata.keys) {
final value = metadata[key];
if (value is List) {
_logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}');
} else if (value is Map) {
_logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}');
} else if (value != null) {
_logger.log('📚 metadata[$key]: $value');
}
}
}
}
// Check for chapters at top level too
if (firstItem.containsKey('chapters')) {
_logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}');
}
}
if (items == null) return [];
final audiobooks = <Audiobook>[];
final parseErrors = <String>[];
for (int i = 0; i < items.length; i++) {
try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
audiobooks.add(book);
} catch (e) {
parseErrors.add('Item $i: $e');
// Skip items that fail to parse
}
}
if (parseErrors.isNotEmpty) {
_logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}');
}
_logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks');
return audiobooks;
} catch (e, stack) {
_logger.log('Error getting audiobooks: $e');
@@ -691,6 +859,26 @@ class MusicAssistantAPI {
}
}
/// Play a radio station
Future<void> playRadioStation(String playerId, MediaItem station) async {
try {
final uri = station.uri ?? 'library://radio/${station.itemId}';
_logger.log('📻 Playing radio station: ${station.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing radio station: $e');
rethrow;
}
}
/// Get full audiobook details including chapters
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
try {
@@ -1051,6 +1239,7 @@ class MusicAssistantAPI {
);
final items = response['result'] as List<dynamic>?;
_logger.log('📋 recently_played_items returned ${items?.length ?? 0} items');
if (items == null || items.isEmpty) {
_logger.log('⚠️ No recently played tracks found');
return [];
@@ -1060,6 +1249,8 @@ class MusicAssistantAPI {
final trackUris = <String>[];
for (final item in items) {
final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
final name = (item as Map<String, dynamic>)['name'] as String?;
_logger.log(' 📀 Track: $name');
if (trackUri != null) {
trackUris.add(trackUri);
}
@@ -1089,12 +1280,17 @@ class MusicAssistantAPI {
try {
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
final trackName = fullTrack?['name'] as String?;
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
final albumUri = albumData?['uri'] as String?;
final albumName = albumData?['name'] as String?;
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
seenAlbumUris.add(albumUri);
albumUris.add(albumUri);
_logger.log(' 📀 Found album: $albumName from track: $trackName');
} else if (albumUri == null) {
_logger.log(' ⚠️ Track "$trackName" has no album');
}
} catch (_) {
continue;
@@ -1363,20 +1559,38 @@ class MusicAssistantAPI {
/// Remove item from favorites
/// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
try {
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
await _sendCommand(
'music/favorites/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
}
await _sendCommand(
'music/favorites/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
} catch (e) {
_logger.log('Error removing from favorites: $e');
rethrow;
}
// Library
/// Add item to library using URI format
/// The item parameter should be a URI like "spotify://artist/4tZwfgrHOc3mvqYlEYSvVi"
Future<void> addItemToLibrary(String mediaType, String itemId, String provider) async {
final uri = '$provider://$mediaType/$itemId';
await _sendCommand(
'music/library/add_item',
args: {
'item': uri,
},
);
}
/// Remove item from library
/// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeItemFromLibrary(String mediaType, int libraryItemId) async {
await _sendCommand(
'music/library/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
}
// Search
@@ -1399,7 +1613,7 @@ class MusicAssistantAPI {
final result = searchResponse['result'] as Map<String, dynamic>?;
if (result == null) {
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': []};
}
// Parse results from the search response
@@ -1428,6 +1642,16 @@ class MusicAssistantAPI {
.toList() ??
[];
final radios = (result['radios'] as List<dynamic>?)
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
final podcasts = (result['podcasts'] as List<dynamic>?)
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
// Deduplicate results by name
// MA returns both library items and provider items for the same content
// Prefer library items (provider='library') as they have all provider mappings
@@ -1437,18 +1661,55 @@ class MusicAssistantAPI {
'tracks': _deduplicateResults(tracks),
'playlists': _deduplicateResults(playlists),
'audiobooks': _deduplicateResults(audiobooks),
'radios': _deduplicateResults(radios),
'podcasts': _deduplicateResults(podcasts),
};
} catch (e) {
_logger.log('Error searching: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
}
},
).catchError((e) {
_logger.log('Error searching after retries: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
});
}
/// Search for podcasts globally (across all providers)
Future<List<MediaItem>> searchPodcasts(String query, {int limit = 25}) async {
try {
_logger.log('🎙️ Searching podcasts for: $query');
final response = await _sendCommand(
'music/search',
args: {
'search_query': query,
'media_types': ['podcast'],
'limit': limit,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result == null) {
_logger.log('🎙️ Podcast search: no result');
return [];
}
// Podcast results might be under 'podcasts' or 'podcast' key
final podcasts = (result['podcasts'] as List<dynamic>?) ??
(result['podcast'] as List<dynamic>?) ??
[];
_logger.log('🎙️ Podcast search found ${podcasts.length} results');
return podcasts
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('🎙️ Error searching podcasts: $e');
return [];
}
}
/// Deduplicate search results by name (case-insensitive)
/// Prefers library items (provider='library') over provider-specific items
/// since library items have complete provider mappings
@@ -1634,6 +1895,86 @@ class MusicAssistantAPI {
}
}
/// Play album on player
Future<void> playAlbum(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Playing album: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing album: $e');
rethrow;
}
}
/// Add album tracks to queue
Future<void> addAlbumToQueue(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Adding album to queue: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding album to queue: $e');
rethrow;
}
}
/// Add artist tracks to queue
Future<void> addArtistToQueue(String playerId, Artist artist) async {
try {
final uri = artist.uri ?? 'library://artist/${artist.itemId}';
_logger.log('🎤 Adding artist to queue: ${artist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding artist to queue: $e');
rethrow;
}
}
/// Play playlist on player
Future<void> playPlaylist(String playerId, Playlist playlist) async {
try {
final uri = playlist.uri ?? 'library://playlist/${playlist.itemId}';
_logger.log('📝 Playing playlist: ${playlist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing playlist: $e');
rethrow;
}
}
/// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued
@@ -2139,6 +2480,22 @@ class MusicAssistantAPI {
return _eventStreams['player_added']!.stream;
}
/// Stream of media_item_added events (for refreshing library when items are added)
Stream<Map<String, dynamic>> get mediaItemAddedEvents {
if (!_eventStreams.containsKey('media_item_added')) {
_eventStreams['media_item_added'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['media_item_added']!.stream;
}
/// Stream of media_item_deleted events (for refreshing library when items are removed)
Stream<Map<String, dynamic>> get mediaItemDeletedEvents {
if (!_eventStreams.containsKey('media_item_deleted')) {
_eventStreams['media_item_deleted'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['media_item_deleted']!.stream;
}
/// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name
@@ -2620,6 +2977,57 @@ class MusicAssistantAPI {
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
}
// ==================== iTunes Artwork Lookup ====================
/// Search iTunes for a podcast and return high-res artwork URL
/// Returns null if not found or on error
Future<String?> getITunesPodcastArtwork(String podcastName) async {
try {
final searchTerm = Uri.encodeComponent(podcastName);
final url = 'https://itunes.apple.com/search?term=$searchTerm&entity=podcast&limit=1';
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 10);
final request = await client.getUrl(Uri.parse(url));
final response = await request.close();
if (response.statusCode != 200) {
_logger.log('🎨 iTunes search failed for "$podcastName": ${response.statusCode}');
return null;
}
final body = await response.transform(utf8.decoder).join();
final json = jsonDecode(body) as Map<String, dynamic>;
final results = json['results'] as List<dynamic>?;
if (results == null || results.isEmpty) {
_logger.log('🎨 No iTunes results for "$podcastName"');
return null;
}
final podcast = results.first as Map<String, dynamic>;
var artworkUrl = podcast['artworkUrl600'] as String?;
if (artworkUrl == null) {
artworkUrl = podcast['artworkUrl100'] as String?;
}
if (artworkUrl != null) {
// Use 800x800 - good quality without being excessive (was 1400x1400)
artworkUrl = artworkUrl
.replaceAll('600x600', '800x800')
.replaceAll('100x100', '800x800');
_logger.log('🎨 Found iTunes artwork for "$podcastName": $artworkUrl');
}
return artworkUrl;
} catch (e) {
_logger.log('🎨 iTunes lookup error for "$podcastName": $e');
return null;
}
}
// ==================== Authentication Methods ====================
/// Authenticate the WebSocket session with a token
+66
View File
@@ -35,6 +35,7 @@ class SettingsService {
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
// Default row order
@@ -66,6 +67,8 @@ class SettingsService {
static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year'
static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list'
// Audiobookshelf Direct Integration Settings
static const String _keyAbsServerUrl = 'abs_server_url';
@@ -81,6 +84,9 @@ class SettingsService {
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyServerUrl);
@@ -484,6 +490,17 @@ class SettingsService {
await prefs.setBool(_keyShowFavoriteTracks, show);
}
// Library Artists Filter - show only artists that have albums in library
static Future<bool> getShowOnlyArtistsWithAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false;
}
static Future<void> setShowOnlyArtistsWithAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show);
}
// Home Row Order
static Future<List<String>> getHomeRowOrder() async {
final prefs = await SharedPreferences.getInstance();
@@ -621,6 +638,26 @@ class SettingsService {
await prefs.setString(_keyLibrarySeriesViewMode, mode);
}
static Future<String> getLibraryRadioViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryRadioViewMode) ?? 'list';
}
static Future<void> setLibraryRadioViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryRadioViewMode, mode);
}
static Future<String> getLibraryPodcastsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2';
}
static Future<void> setLibraryPodcastsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryPodcastsViewMode, mode);
}
// View Mode Settings - Series Audiobooks (series detail screen)
static Future<String> getSeriesAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
@@ -786,4 +823,33 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyHasCompletedOnboarding, completed);
}
// Podcast Cover Cache (iTunes URLs for high-res artwork)
// Stored as JSON: {"podcastId": "itunesUrl", ...}
/// Get cached podcast cover URLs (iTunes high-res)
static Future<Map<String, String>> getPodcastCoverCache() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyPodcastCoverCache);
if (json == null) return {};
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value as String));
} catch (_) {
return {};
}
}
/// Save podcast cover cache
static Future<void> setPodcastCoverCache(Map<String, String> cache) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache));
}
/// Add a single podcast cover to cache (for incremental updates)
static Future<void> addPodcastCoverToCache(String podcastId, String url) async {
final cache = await getPodcastCoverCache();
cache[podcastId] = url;
await setPodcastCoverCache(cache);
}
}
+12 -1
View File
@@ -5,6 +5,7 @@ import '../models/media_item.dart';
import 'database_service.dart';
import 'debug_logger.dart';
import 'music_assistant_api.dart';
import 'settings_service.dart';
/// Sync status for UI indicators
enum SyncStatus {
@@ -150,10 +151,14 @@ class SyncService with ChangeNotifier {
try {
_logger.log('🔄 Starting background library sync...');
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
// Fetch fresh data from MA API (in parallel for speed)
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
@@ -166,6 +171,12 @@ class SyncService with ChangeNotifier {
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
// Clear old cache before saving fresh data (removes stale items)
await _db.clearCacheForType('album');
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
// Save to database cache
await _saveAlbumsToCache(albums);
await _saveArtistsToCache(artists);
+31 -3
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -13,12 +14,16 @@ class AlbumCard extends StatefulWidget {
final Album album;
final VoidCallback? onTap;
final String? heroTagSuffix;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const AlbumCard({
super.key,
required this.album,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
@@ -30,6 +35,10 @@ class _AlbumCardState extends State<AlbumCard> {
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -37,6 +46,12 @@ class _AlbumCardState extends State<AlbumCard> {
_initFallbackImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
void _initFallbackImage() {
// Check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -47,6 +62,16 @@ class _AlbumCardState extends State<AlbumCard> {
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_scheduleFallbackFetch();
}
});
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
@@ -69,7 +94,7 @@ class _AlbumCardState extends State<AlbumCard> {
if (!_triedFallback && !_maImageFailed) {
_maImageFailed = true;
_triedFallback = true;
_fetchFallbackImage();
_scheduleFallbackFetch();
}
}
@@ -86,6 +111,9 @@ class _AlbumCardState extends State<AlbumCard> {
// Use fallback if MA image failed or wasn't available
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
@@ -118,8 +146,8 @@ class _AlbumCardState extends State<AlbumCard> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
+11 -7
View File
@@ -126,6 +126,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _albums.length,
@@ -135,13 +136,16 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = _albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
),
);
},
+29 -6
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
@@ -32,6 +33,10 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
String? _fallbackImageUrl;
bool _triedFallback = false;
bool _maImageFailed = false;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -39,6 +44,12 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
_loadImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
Future<void> _loadImage() async {
final provider = context.read<MusicAssistantProvider>();
@@ -56,14 +67,23 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
return;
}
// Fallback to external sources (MA returned null)
_fetchFallbackImage();
// Fallback to external sources (MA returned null) - with delay
_scheduleFallbackFetch();
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
if (_triedFallback) return;
_triedFallback = true;
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
}
Future<void> _fetchFallbackImage() async {
if (_triedFallback) return;
_triedFallback = true;
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
_fallbackImageUrl = fallbackUrl;
@@ -79,7 +99,7 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
// When MA image fails to load, try Deezer fallback
if (!_maImageFailed) {
_maImageFailed = true;
_fetchFallbackImage();
_scheduleFallbackFetch();
}
}
@@ -100,6 +120,9 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
width: widget.radius * 2,
height: widget.radius * 2,
fit: BoxFit.cover,
// PERF: Use imageSize for memory cache to reduce decode overhead
memCacheWidth: widget.imageSize,
memCacheHeight: widget.imageSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
+34 -7
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -14,14 +15,16 @@ class ArtistCard extends StatefulWidget {
final Artist artist;
final VoidCallback? onTap;
final String? heroTagSuffix;
final double? imageSize;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const ArtistCard({
super.key,
required this.artist,
this.onTap,
this.heroTagSuffix,
this.imageSize,
this.imageCacheSize,
});
@override
@@ -34,6 +37,10 @@ class _ArtistCardState extends State<ArtistCard> {
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -42,6 +49,12 @@ class _ArtistCardState extends State<ArtistCard> {
_initFallbackImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
void _initFallbackImage() {
// We'll check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -52,7 +65,17 @@ class _ArtistCardState extends State<ArtistCard> {
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_logger.debug('No MA image for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
_scheduleFallbackFetch();
}
});
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
@@ -63,8 +86,8 @@ class _ArtistCardState extends State<ArtistCard> {
if (!_triedFallback && !_maImageFailed) {
_maImageFailed = true;
_triedFallback = true;
_logger.debug('MA image failed for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage();
_logger.debug('MA image failed for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
_scheduleFallbackFetch();
}
}
@@ -81,6 +104,9 @@ class _ArtistCardState extends State<ArtistCard> {
// Use fallback if MA image failed or wasn't available
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
@@ -115,8 +141,8 @@ class _ArtistCardState extends State<ArtistCard> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
@@ -163,6 +189,7 @@ class _ArtistCardState extends State<ArtistCard> {
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
),
+27
View File
@@ -31,6 +31,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
late Future<List<Audiobook>> _audiobooksFuture;
List<Audiobook>? _cachedAudiobooks;
bool _hasLoaded = false;
bool _hasPrecachedAudiobooks = false;
@override
bool get wantKeepAlive => true;
@@ -45,12 +46,38 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
if (!_hasLoaded) {
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
_cachedAudiobooks = audiobooks;
// Pre-cache images for smooth Hero animations
_precacheAudiobookImages(audiobooks);
return audiobooks;
});
_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();
@override
+126 -35
View File
@@ -583,9 +583,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final mappings = currentTrack.providerMappings as List<dynamic>?;
if (mappings != null && mappings.isNotEmpty) {
// Find a non-library provider mapping
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
for (final mapping in mappings) {
if (mapping.available == true && mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
break;
}
@@ -594,7 +595,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (actualProvider == 'library') {
for (final mapping in mappings) {
if (mapping.available == true) {
actualProvider = mapping.providerInstance;
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
break;
}
@@ -1068,7 +1069,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
ThemeProvider themeProvider,
) {
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 isDark = Theme.of(context).brightness == Brightness.dark;
@@ -1143,6 +1145,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider);
},
onPowerToggle: () => maProvider.togglePower(selectedPlayer.playerId),
),
),
);
@@ -1157,7 +1160,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
ThemeProvider themeProvider,
) {
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 colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -1196,10 +1201,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final primaryColor = adaptiveScheme?.primary ?? Colors.white;
// Always position above bottom nav bar
// Overlap by 2px when expanded to eliminate any subpixel rendering gaps
final bottomNavSpace = _bottomNavHeight + bottomPadding;
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
final expandedBottomOffset = bottomNavSpace;
final expandedHeight = screenSize.height - bottomNavSpace;
final expandedBottomOffset = bottomNavSpace - 2;
final expandedHeight = screenSize.height - bottomNavSpace + 2;
// Apply slide offset to hide mini player (slides down off-screen)
// Apply bounce offset for reveal animation (small downward movement)
@@ -1301,7 +1307,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// - Progress bar + times: ~70px
// Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding
// 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 playButtonHalfHeight = 36.0; // Half of the 72px play button container
final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight;
@@ -1500,7 +1507,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
borderRadius: BorderRadius.circular(borderRadius),
elevation: _lerpDouble(4, 0, t),
shadowColor: Colors.black.withOpacity(0.3),
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(
width: width,
height: height,
@@ -1694,10 +1703,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: SizedBox(
width: titleWidth,
child: Text(
// Always show artist/author (was showing "Now Playing" when device reveal visible)
// Always show artist/author/podcast name (was showing "Now Playing" when device reveal visible)
maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
: maProvider.isPlayingPodcast
? (maProvider.currentPodcastName ?? S.of(context)!.podcasts)
: currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
fontSize: artistFontSize,
@@ -1734,7 +1745,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Album name OR Chapter name (expanded only)
// GPU PERF: Use color alpha instead of Opacity widget
// For audiobooks: show current chapter; for music: show album
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook))
// For podcasts: hide album line since podcast name is already shown in artist position
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook) && !maProvider.isPlayingPodcast)
Positioned(
left: contentPadding,
right: contentPadding,
@@ -1760,7 +1772,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Positioned(
left: contentPadding,
right: contentPadding,
top: expandedAlbumTop + (currentTrack.album != null ? 24 : 0),
top: expandedAlbumTop + (maProvider.isPlayingAudiobook ? 24 : (currentTrack.album != null ? 24 : 0)),
child: FadeTransition(
opacity: _expandAnimation.drive(
Tween(begin: 0.0, end: 1.0).chain(
@@ -1883,23 +1895,76 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
// Playback controls - with slide animation when collapsed
// Use skip 30s controls for audiobooks and podcasts
Positioned(
left: t > 0.5 ? 0 : null,
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
top: controlsTop,
child: maProvider.isPlayingAudiobook
? _buildAudiobookControls(
maProvider: maProvider,
selectedPlayer: selectedPlayer,
textColor: textColor,
primaryColor: primaryColor,
backgroundColor: backgroundColor,
skipButtonSize: skipButtonSize,
playButtonSize: playButtonSize,
playButtonContainerSize: playButtonContainerSize,
t: t,
expandedElementsOpacity: expandedElementsOpacity,
)
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
// When device reveal is visible (player list shown), use compact controls
// for audiobooks/podcasts: Play, Forward 30, Power
? widget.isDeviceRevealVisible && t < 0.5
? Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Play/Pause - compact like PlayerCard
Transform.translate(
offset: const Offset(3, 0),
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
// like PlayerCard: Play, Next, Power - matching other players in the list
: widget.isDeviceRevealVisible && t < 0.5
@@ -2330,7 +2395,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
/// 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({
required MusicAssistantProvider maProvider,
required dynamic selectedPlayer,
@@ -2344,19 +2410,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
required double expandedElementsOpacity,
}) {
final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false;
final isExpanded = t > 0.5;
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
// Previous Chapter (expanded only, if chapters available)
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
_buildSecondaryButton(
icon: Icons.skip_previous_rounded,
color: textColor.withOpacity(expandedElementsOpacity),
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
_buildControlButton(
@@ -2364,9 +2431,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
color: textColor,
size: skipButtonSize,
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
_buildPlayButton(
@@ -2380,7 +2459,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
onPressed: () => maProvider.playPauseSelectedPlayer(),
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
_buildControlButton(
@@ -2388,12 +2479,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
color: textColor,
size: skipButtonSize,
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
useAnimation: t > 0.5,
useAnimation: isExpanded,
),
// Next Chapter (expanded only, if chapters available)
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
_buildSecondaryButton(
icon: Icons.skip_next_rounded,
color: textColor.withOpacity(expandedElementsOpacity),
+4 -2
View File
@@ -643,7 +643,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
left: 24,
right: 24,
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
// Use viewPadding to match BottomNavigationBar's height calculation
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).viewPadding.bottom + 32,
child: FadeTransition(
opacity: CurvedAnimation(
parent: _welcomeFadeController,
@@ -684,7 +685,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
// Use viewPadding to match BottomNavigationBar's height calculation
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal,
),
+255
View File
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
/// A scrollbar that shows a letter popup when dragging, for fast navigation
/// through alphabetically sorted lists.
class LetterScrollbar extends StatefulWidget {
/// The scrollable child widget (ListView, GridView, etc.)
final Widget child;
/// The scroll controller for the child
final ScrollController controller;
/// List of items to extract letters from (must be sorted alphabetically)
/// Each item's first character is used to determine the letter
final List<String> items;
/// Callback when user taps/drags to a specific index
final void Function(int index)? onScrollToIndex;
/// Callback when drag state changes (for disabling scroll-to-hide)
final void Function(bool isDragging)? onDragStateChanged;
const LetterScrollbar({
super.key,
required this.child,
required this.controller,
required this.items,
this.onScrollToIndex,
this.onDragStateChanged,
});
@override
State<LetterScrollbar> createState() => _LetterScrollbarState();
}
class _LetterScrollbarState extends State<LetterScrollbar> {
bool _isDragging = false;
String _currentLetter = '';
double _dragPosition = 0;
double _scrollFraction = 0;
@override
void initState() {
super.initState();
widget.controller.addListener(_onScrollChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onScrollChanged);
super.dispose();
}
void _onScrollChanged() {
if (!_isDragging && widget.controller.hasClients) {
final position = widget.controller.position;
if (position.maxScrollExtent > 0) {
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
if ((newFraction - _scrollFraction).abs() > 0.01) {
setState(() {
_scrollFraction = newFraction;
});
}
}
}
}
// Build a map of letter -> first index for that letter
Map<String, int> get _letterIndexMap {
final map = <String, int>{};
for (int i = 0; i < widget.items.length; i++) {
final item = widget.items[i];
if (item.isNotEmpty) {
final letter = item[0].toUpperCase();
if (!map.containsKey(letter)) {
map[letter] = i;
}
}
}
return map;
}
String _getLetterAtPosition(double position, double maxHeight) {
if (widget.items.isEmpty || maxHeight <= 0) return '';
// Calculate which item index corresponds to this position
final fraction = (position / maxHeight).clamp(0.0, 1.0);
final index = (fraction * (widget.items.length - 1)).round();
if (index >= 0 && index < widget.items.length) {
final item = widget.items[index];
if (item.isNotEmpty) {
return item[0].toUpperCase();
}
}
return '';
}
void _scrollToLetter(String letter) {
final letterMap = _letterIndexMap;
if (letterMap.containsKey(letter)) {
final index = letterMap[letter]!;
if (widget.onScrollToIndex != null) {
widget.onScrollToIndex!(index);
} else {
// Estimate scroll position based on index
// This is approximate - works better with fixed height items
final scrollController = widget.controller;
if (scrollController.hasClients) {
final maxScroll = scrollController.position.maxScrollExtent;
final fraction = index / widget.items.length;
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
// Update scroll fraction immediately for smooth thumb tracking
setState(() {
_scrollFraction = fraction;
});
scrollController.jumpTo(targetScroll);
}
}
}
}
void _handleDragStart(DragStartDetails details) {
setState(() {
_isDragging = true;
_dragPosition = details.localPosition.dy;
});
widget.onDragStateChanged?.call(true);
_updateLetterAndScroll(details.localPosition.dy);
}
void _handleDragUpdate(DragUpdateDetails details) {
setState(() {
_dragPosition = details.localPosition.dy;
});
_updateLetterAndScroll(details.localPosition.dy);
}
void _handleDragEnd(DragEndDetails details) {
setState(() {
_isDragging = false;
});
widget.onDragStateChanged?.call(false);
}
void _updateLetterAndScroll(double position) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final height = renderBox.size.height;
final letter = _getLetterAtPosition(position, height);
if (letter.isNotEmpty && letter != _currentLetter) {
setState(() {
_currentLetter = letter;
});
_scrollToLetter(letter);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Stack(
children: [
// The scrollable content
widget.child,
// The draggable scrollbar area (right edge) - wider grab area
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 32, // Wider grab area
child: GestureDetector(
onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
onTapDown: (details) {
_handleDragStart(DragStartDetails(
localPosition: details.localPosition,
globalPosition: details.globalPosition,
));
},
onTapUp: (_) {
setState(() {
_isDragging = false;
});
widget.onDragStateChanged?.call(false);
},
behavior: HitTestBehavior.translucent,
child: LayoutBuilder(
builder: (context, constraints) {
final trackHeight = constraints.maxHeight;
final thumbHeight = _isDragging ? 60.0 : 40.0;
final availableTrack = trackHeight - thumbHeight;
final thumbTop = availableTrack * _scrollFraction;
return Stack(
children: [
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
AnimatedPositioned(
duration: const Duration(milliseconds: 50),
curve: Curves.linear,
top: thumbTop,
right: 4,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: _isDragging ? 6 : 4,
height: thumbHeight,
decoration: BoxDecoration(
color: _isDragging
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.3),
borderRadius: BorderRadius.circular(3),
),
),
),
],
);
},
),
),
),
// The letter popup bubble
if (_isDragging && _currentLetter.isNotEmpty)
Positioned(
right: 48,
top: _dragPosition - 28,
child: Material(
elevation: 4,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
_currentLetter,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
);
}
}
@@ -16,6 +16,7 @@ class DeviceSelectorBar extends StatelessWidget {
final GestureDragStartCallback? onHorizontalDragStart;
final GestureDragUpdateCallback? onHorizontalDragUpdate;
final GestureDragEndCallback? onHorizontalDragEnd;
final VoidCallback? onPowerToggle;
const DeviceSelectorBar({
super.key,
@@ -31,6 +32,7 @@ class DeviceSelectorBar extends StatelessWidget {
this.onHorizontalDragStart,
this.onHorizontalDragUpdate,
this.onHorizontalDragEnd,
this.onPowerToggle,
});
@override
@@ -68,6 +70,8 @@ class DeviceSelectorBar extends StatelessWidget {
width: width,
slideOffset: slideOffset,
isHint: swipeHint != null,
onPowerToggle: onPowerToggle,
isPoweredOn: selectedPlayer.powered,
),
],
),
+53 -12
View File
@@ -8,17 +8,20 @@ class MiniPlayerLayout {
static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 82px
// 3-line layout: track, artist, player
static const double primaryTop = 7.0;
static const double secondaryTop = 27.0;
static const double primaryTop = 5.0; // raised slightly from 7
static const double secondaryTop = 26.0; // raised by 1
static const double tertiaryTop = 46.0;
// 2-line layout (centered): player name, hint
static const double primaryTop2Line = 16.0;
static const double secondaryTop2Line = 36.0;
// 2-line layout (evenly spaced): player name, hint
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
static const double primaryTop2Line = 14.0; // 24 - (18/2) = 15, adjusted to 14
static const double secondaryTop2Line = 40.0; // raised slightly from 42
static const double textRightPadding = 12.0;
static const double powerButtonSize = 40.0; // Power button tap area
static const double iconSize = 28.0;
static const double iconOpacity = 0.4;
static const double secondaryTextOpacity = 0.6;
static const double primaryFontSize = 16.0;
static const double primaryFontSize = 16.0; // 3-line playing mode
static const double primaryFontSize2Line = 18.0; // larger for 2-line off mode
static const double secondaryFontSize = 14.0;
static const double tertiaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500;
@@ -67,6 +70,12 @@ class MiniPlayerContent extends StatelessWidget {
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
final double progress;
/// Callback for power button tap (only shown in 2-line mode when provided)
final VoidCallback? onPowerToggle;
/// Whether the player is currently powered on
final bool isPoweredOn;
const MiniPlayerContent({
super.key,
required this.primaryText,
@@ -81,24 +90,35 @@ class MiniPlayerContent extends StatelessWidget {
this.isHint = false,
this.showProgress = false,
this.progress = 0.0,
this.onPowerToggle,
this.isPoweredOn = true,
});
@override
Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
final is2LineMode = !hasTertiaryLine;
final showPowerButton = is2LineMode && onPowerToggle != null;
final slidePixels = slideOffset * width;
// Use 3-line layout when tertiary exists, 2-line centered otherwise
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
// Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
// Calculate right padding (extra space for power button in 2-line mode)
final rightPadding = showPowerButton
? MiniPlayerLayout.powerButtonSize + 8.0
: MiniPlayerLayout.textRightPadding;
// Darkened background for icon area (matches DeviceSelectorBar)
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
// Font size for primary text (larger in 2-line mode)
final primaryFontSize = is2LineMode
? MiniPlayerLayout.primaryFontSize2Line
: MiniPlayerLayout.primaryFontSize;
return SizedBox(
width: width,
height: MiniPlayerLayout.height,
@@ -142,13 +162,13 @@ class MiniPlayerContent extends StatelessWidget {
left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine
? primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels,
: (MiniPlayerLayout.height - primaryFontSize) / 2,
right: rightPadding - slidePixels,
child: Text(
primaryText,
style: TextStyle(
color: textColor,
fontSize: MiniPlayerLayout.primaryFontSize,
fontSize: primaryFontSize,
fontWeight: MiniPlayerLayout.primaryFontWeight,
),
maxLines: 1,
@@ -161,7 +181,7 @@ class MiniPlayerContent extends StatelessWidget {
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
right: rightPadding - slidePixels,
child: isHint
? Row(
mainAxisSize: MainAxisSize.min,
@@ -212,6 +232,27 @@ class MiniPlayerContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
// Power button (only for 2-line mode when callback provided)
// Matches PlayerCard power button style
if (showPowerButton)
Positioned(
right: 8.0 - slidePixels,
top: 0,
bottom: 0,
child: Center(
child: IconButton(
onPressed: onPowerToggle,
icon: Icon(
Icons.power_settings_new_rounded,
color: isPoweredOn ? textColor : textColor.withOpacity(0.5),
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
),
],
),
);
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.7.4-beta+36
version: 2.8.2-beta+39
environment:
sdk: '>=3.0.0 <4.0.0'