Compare commits

...
38 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 8e548daf0c Fix black screen bug when Material You theme enabled (#37)
Override Material You ColorScheme backgrounds with app's preferred colors.
Material You generates unpredictable background values that could cause
black screen or inconsistent navigation bar colors.

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

Bump version to 2.8.3-beta+40

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

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

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

Fixes #38

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:03:17 +00:00
Your NameandClaude Opus 4.5 aa74fe96da Bump version to 2.8.1-beta+38
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also made the settings save async and added verification logging.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now buttons visually update immediately when library status changes.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:32:31 +00:00
28 changed files with 3896 additions and 751 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`
+23
View File
@@ -1066,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",
@@ -1355,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"
}
}
}
}
+10 -3
View File
@@ -229,10 +229,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
ColorScheme? darkColorScheme;
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
// Use system color schemes
// Use system color schemes, but override background to match app design
// Material You doesn't set background consistently, causing black screen issues
final (light, dark) = snapshot.data!;
lightColorScheme = light;
darkColorScheme = dark;
lightColorScheme = light.copyWith(
surface: light.surface,
background: const Color(0xFFFAFAFA), // App's preferred light background
);
darkColorScheme = dark.copyWith(
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
background: const Color(0xFF1a1a1a), // App's preferred dark background
);
} else {
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
+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;
+644 -47
View File
@@ -76,6 +76,8 @@ class MusicAssistantProvider with ChangeNotifier {
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
StreamSubscription? _playerAddedEventSubscription;
StreamSubscription? _mediaItemAddedEventSubscription;
StreamSubscription? _mediaItemDeletedEventSubscription;
Timer? _localPlayerStateReportTimer;
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
@@ -103,6 +105,14 @@ class MusicAssistantProvider with ChangeNotifier {
// This is populated with episode covers when podcast covers are low-res
final Map<String, String> _podcastCoverCache = {};
// Provider filter: list of allowed provider instance IDs from MA user settings
// Empty list means all providers are allowed
List<String> _providerFilter = [];
// Player filter: list of allowed player IDs from MA user settings
// Empty list means all players are allowed
List<String> _playerFilter = [];
// ============================================================================
// GETTERS
// ============================================================================
@@ -113,15 +123,86 @@ class MusicAssistantProvider with ChangeNotifier {
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
List<Artist> get artists => _artists;
List<Album> get albums => _albums;
List<Track> get tracks => _tracks;
List<MediaItem> get radioStations => _radioStations;
List<MediaItem> get podcasts => _podcasts;
// Library getters with provider filtering applied
List<Artist> get artists => filterByProvider(_artists);
List<Album> get albums => filterByProvider(_albums);
List<Track> get tracks => filterByProvider(_tracks);
List<MediaItem> get radioStations => filterByProvider(_radioStations);
List<MediaItem> get podcasts => filterByProvider(_podcasts);
// Raw unfiltered access (for internal use only)
List<Artist> get artistsUnfiltered => _artists;
List<Album> get albumsUnfiltered => _albums;
bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
/// Provider filter from MA user settings (empty = all providers allowed)
List<String> get providerFilter => _providerFilter;
/// Whether provider filtering is active
bool get hasProviderFilter => _providerFilter.isNotEmpty;
/// Check if a media item should be visible based on provider filter
/// Returns true if:
/// - No filter is active (empty list = all providers allowed)
/// - The item has at least one provider mapping in the allowed list
/// - The item's primary provider is in the allowed list
bool isItemAllowedByProviderFilter(MediaItem item) {
// No filter = show everything
if (_providerFilter.isEmpty) return true;
// Check if item's provider mappings include any allowed provider
final mappings = item.providerMappings;
if (mappings != null && mappings.isNotEmpty) {
for (final mapping in mappings) {
if (_providerFilter.contains(mapping.providerInstance)) {
return true;
}
}
}
// Also check primary provider field (for items without full mappings)
if (_providerFilter.contains(item.provider)) {
return true;
}
return false;
}
/// Filter a list of media items based on provider filter
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
if (_providerFilter.isEmpty) return items;
return items.where(isItemAllowedByProviderFilter).toList();
}
/// Filter search results map based on provider filter
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
if (_providerFilter.isEmpty) return results;
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
};
}
/// Player filter from MA user settings (empty = all players allowed)
List<String> get playerFilter => _playerFilter;
/// Whether player filtering is active
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
/// Check if a player should be visible based on player filter
bool isPlayerAllowedByFilter(Player player) {
if (_playerFilter.isEmpty) return true;
return _playerFilter.contains(player.playerId);
}
/// Filter a list of players based on player filter
List<Player> filterPlayers(List<Player> players) {
if (_playerFilter.isEmpty) return players;
return players.where(isPlayerAllowedByFilter).toList();
}
/// Whether library is syncing in background
bool get isSyncing => SyncService.instance.isSyncing;
@@ -137,14 +218,18 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Available players - loads from cache for instant UI display
/// Filtered by player_filter if active
List<Player> get availablePlayers {
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_logger.log('⚡ Loaded ${_availablePlayers.length} players from cache (lazy)');
}
return _availablePlayers;
return filterPlayers(_availablePlayers);
}
/// Raw unfiltered players list (for internal use)
List<Player> get availablePlayersUnfiltered => _availablePlayers;
/// Check if a player should show the "manually synced" indicator (yellow border)
/// Returns true for BOTH the leader AND children of a manually created sync group
/// Excludes pre-configured MA speaker groups (provider = 'player_group')
@@ -227,60 +312,61 @@ class MusicAssistantProvider with ChangeNotifier {
return uri.contains('podcast_episode') || uri.contains('podcast/');
}
/// Whether we're currently playing a radio station
/// Detected by checking if the current track's URI contains 'radio/' or media_type is 'radio'
bool get isPlayingRadio {
final uri = _currentTrack?.uri;
if (uri == null) return false;
return uri.contains('library://radio/') || uri.contains('/radio/');
}
/// Get the radio station name when playing a radio stream
/// Returns the station name from album (where MA puts it for radio streams)
String? get currentRadioStationName {
if (!isPlayingRadio || _currentTrack == null) return null;
// For radio, the station name is typically in the album field
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
return _currentTrack!.album!.name;
}
return null;
}
/// Get the podcast name when playing a podcast episode
/// Returns the podcast name from metadata, album name, or artist name
String? get currentPodcastName {
if (!isPlayingPodcast || _currentTrack == null) return null;
// Debug logging to understand podcast data structure
_logger.log('🎙️ Getting podcast name for: ${_currentTrack!.name}');
_logger.log('🎙️ Track URI: ${_currentTrack!.uri}');
_logger.log('🎙️ Album: ${_currentTrack!.album?.name}');
_logger.log('🎙️ Artists: ${_currentTrack!.artists?.map((a) => a.name).join(", ")}');
_logger.log('🎙️ Metadata keys: ${_currentTrack!.metadata?.keys.toList()}');
// Try metadata first (if episode has parent podcast info)
final metadata = _currentTrack!.metadata;
if (metadata != null) {
// Log all metadata for debugging
_logger.log('🎙️ Full metadata: $metadata');
// Check for podcast_name or podcast.name in metadata
if (metadata['podcast_name'] != null) {
_logger.log('🎙️ Found podcast_name in metadata');
return metadata['podcast_name'] as String;
}
if (metadata['podcast'] is Map) {
final podcast = metadata['podcast'] as Map;
if (podcast['name'] != null) {
_logger.log('🎙️ Found podcast.name in metadata');
return podcast['name'] as String;
}
}
// Check for 'title' in metadata (some providers put podcast name here)
if (metadata['title'] != null && metadata['title'] != _currentTrack!.name) {
_logger.log('🎙️ Found title in metadata: ${metadata['title']}');
}
}
// Fall back to album name (often contains podcast name)
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
_logger.log('🎙️ Using album name as podcast name');
return _currentTrack!.album!.name;
}
// Fall back to artist (sometimes the podcast name is set as artist)
if (_currentTrack!.artists != null && _currentTrack!.artists!.isNotEmpty) {
final artistName = _currentTrack!.artists!.first.name;
if (artistName != 'Unknown Artist' && artistName.isNotEmpty) {
_logger.log('🎙️ Using artist name as podcast name');
return artistName;
}
}
_logger.log('🎙️ No podcast name found, returning null');
return null;
}
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults);
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
@@ -722,6 +808,19 @@ class MusicAssistantProvider with ChangeNotifier {
onError: (error) => _logger.log('Player added event stream error: $error'),
);
// Subscribe to library change events for instant UI updates
_mediaItemAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription = _api!.mediaItemAddedEvents.listen(
_handleMediaItemAddedEvent,
onError: (error) => _logger.log('Media item added event stream error: $error'),
);
_mediaItemDeletedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription = _api!.mediaItemDeletedEvents.listen(
_handleMediaItemDeletedEvent,
onError: (error) => _logger.log('Media item deleted event stream error: $error'),
);
await _api!.connect();
notifyListeners();
} catch (e) {
@@ -744,7 +843,7 @@ class MusicAssistantProvider with ChangeNotifier {
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchAndSetUserProfileName();
await _fetchUserSettings();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
@@ -769,7 +868,7 @@ class MusicAssistantProvider with ChangeNotifier {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchAndSetUserProfileName();
await _fetchUserSettings();
return true;
}
}
@@ -782,13 +881,14 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> _fetchAndSetUserProfileName() async {
Future<void> _fetchUserSettings() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) return;
// Set profile name
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
@@ -798,8 +898,38 @@ class MusicAssistantProvider with ChangeNotifier {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
}
// Capture provider filter (empty list = all providers allowed)
final providerFilterRaw = userInfo['provider_filter'];
if (providerFilterRaw is List) {
_providerFilter = providerFilterRaw.cast<String>().toList();
if (_providerFilter.isNotEmpty) {
_logger.log('🔒 Provider filter active: ${_providerFilter.length} providers allowed');
_logger.log(' Allowed: ${_providerFilter.join(", ")}');
} else {
_logger.log('🔓 No provider filter - all providers visible');
}
} else {
_providerFilter = [];
_logger.log('🔓 No provider filter in user settings');
}
// Capture player filter (empty list = all players allowed)
final playerFilterRaw = userInfo['player_filter'];
if (playerFilterRaw is List) {
_playerFilter = playerFilterRaw.cast<String>().toList();
if (_playerFilter.isNotEmpty) {
_logger.log('🔒 Player filter active: ${_playerFilter.length} players allowed');
_logger.log(' Allowed: ${_playerFilter.join(", ")}');
} else {
_logger.log('🔓 No player filter - all players visible');
}
} else {
_playerFilter = [];
_logger.log('🔓 No player filter in user settings');
}
} catch (e) {
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
}
}
@@ -812,7 +942,7 @@ class MusicAssistantProvider with ChangeNotifier {
await _api!.fetchState();
if (_api!.authRequired) {
await _fetchAndSetUserProfileName();
await _fetchUserSettings();
}
await _tryAdoptGhostPlayer();
@@ -906,6 +1036,8 @@ class MusicAssistantProvider with ChangeNotifier {
// Online - execute immediately
try {
await _api!.addToFavorites(mediaType, itemId, provider);
// Update local cache for instant UI feedback
_updateLocalFavoriteStatus(mediaType, itemId, true);
return true;
} catch (e) {
_logger.log('❌ Failed to add to favorites: $e');
@@ -923,6 +1055,8 @@ class MusicAssistantProvider with ChangeNotifier {
},
);
_logger.log('📋 Queued add to favorites (offline): $mediaType');
// Still update local state for offline support
_updateLocalFavoriteStatus(mediaType, itemId, true);
return true;
}
}
@@ -937,6 +1071,8 @@ class MusicAssistantProvider with ChangeNotifier {
// Online - execute immediately
try {
await _api!.removeFromFavorites(mediaType, libraryItemId);
// Update local cache for instant UI feedback
_updateLocalFavoriteStatusByLibraryId(mediaType, libraryItemId, false);
return true;
} catch (e) {
_logger.log('❌ Failed to remove from favorites: $e');
@@ -953,10 +1089,330 @@ class MusicAssistantProvider with ChangeNotifier {
},
);
_logger.log('📋 Queued remove from favorites (offline): $mediaType');
// Still update local state for offline support
_updateLocalFavoriteStatusByLibraryId(mediaType, libraryItemId, false);
return true;
}
}
/// Update favorite status in local cache for instant UI feedback
void _updateLocalFavoriteStatus(String mediaType, String itemId, bool isFavorite) {
bool updated = false;
if (mediaType == 'artist') {
final index = _artists.indexWhere((a) => a.itemId == itemId);
if (index != -1) {
final artist = _artists[index];
_artists[index] = Artist(
itemId: artist.itemId,
provider: artist.provider,
name: artist.name,
sortName: artist.sortName,
uri: artist.uri,
providerMappings: artist.providerMappings,
metadata: artist.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'album') {
final index = _albums.indexWhere((a) => a.itemId == itemId);
if (index != -1) {
final album = _albums[index];
_albums[index] = Album(
itemId: album.itemId,
provider: album.provider,
name: album.name,
artists: album.artists,
albumType: album.albumType,
year: album.year,
sortName: album.sortName,
uri: album.uri,
providerMappings: album.providerMappings,
metadata: album.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'track') {
final index = _tracks.indexWhere((t) => t.itemId == itemId);
if (index != -1) {
final track = _tracks[index];
_tracks[index] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
artists: track.artists,
album: track.album,
duration: track.duration,
sortName: track.sortName,
uri: track.uri,
providerMappings: track.providerMappings,
metadata: track.metadata,
favorite: isFavorite,
);
updated = true;
}
}
if (updated) {
notifyListeners();
}
}
/// Update favorite status by library item ID (for remove operations)
void _updateLocalFavoriteStatusByLibraryId(String mediaType, int libraryItemId, bool isFavorite) {
final libraryIdStr = libraryItemId.toString();
bool updated = false;
if (mediaType == 'artist') {
final index = _artists.indexWhere((a) =>
a.provider == 'library' && a.itemId == libraryIdStr ||
a.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true
);
if (index != -1) {
final artist = _artists[index];
_artists[index] = Artist(
itemId: artist.itemId,
provider: artist.provider,
name: artist.name,
sortName: artist.sortName,
uri: artist.uri,
providerMappings: artist.providerMappings,
metadata: artist.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'album') {
final index = _albums.indexWhere((a) =>
a.provider == 'library' && a.itemId == libraryIdStr ||
a.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true
);
if (index != -1) {
final album = _albums[index];
_albums[index] = Album(
itemId: album.itemId,
provider: album.provider,
name: album.name,
artists: album.artists,
albumType: album.albumType,
year: album.year,
sortName: album.sortName,
uri: album.uri,
providerMappings: album.providerMappings,
metadata: album.metadata,
favorite: isFavorite,
);
updated = true;
}
} else if (mediaType == 'track') {
final index = _tracks.indexWhere((t) =>
t.provider == 'library' && t.itemId == libraryIdStr ||
t.providerMappings?.any((m) => m.providerInstance == 'library' && m.itemId == libraryIdStr) == true
);
if (index != -1) {
final track = _tracks[index];
_tracks[index] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
artists: track.artists,
album: track.album,
duration: track.duration,
sortName: track.sortName,
uri: track.uri,
providerMappings: track.providerMappings,
metadata: track.metadata,
favorite: isFavorite,
);
updated = true;
}
}
if (updated) {
notifyListeners();
}
}
// ============================================================================
// LIBRARY MANAGEMENT
// ============================================================================
/// Add item to library
/// Returns true if action was executed successfully
Future<bool> addToLibrary({
required String mediaType,
required String itemId,
required String provider,
}) async {
if (isConnected && _api != null) {
try {
await _api!.addItemToLibrary(mediaType, itemId, provider);
// Trigger a background refresh to update library with new item
_scheduleLibraryRefresh(mediaType);
// Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache();
if (mediaType == 'album') {
_cacheService.invalidateArtistAlbumsCache();
_cacheService.invalidateHomeAlbumCaches();
} else if (mediaType == 'track') {
_cacheService.invalidateAllAlbumTracksCaches();
_cacheService.invalidateAllPlaylistTracksCaches();
} else if (mediaType == 'artist') {
_cacheService.invalidateHomeArtistCaches();
}
return true;
} catch (e) {
_logger.log('❌ Failed to add to library: $e');
return false;
}
} else {
_logger.log('❌ Cannot add to library while offline');
return false;
}
}
/// Remove item from library
/// Returns true if action was executed successfully
/// Uses optimistic update: local cache is updated immediately before API call
Future<bool> removeFromLibrary({
required String mediaType,
required int libraryItemId,
}) async {
if (isConnected && _api != null) {
// OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback
// This ensures library screens show the change even before API completes
_removeFromLocalLibrary(mediaType, libraryItemId);
// Also mark as deleted in database cache so it doesn't reappear on next load
DatabaseService.instance.markCachedItemDeleted(mediaType, libraryItemId.toString());
// Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache();
if (mediaType == 'album') {
_cacheService.invalidateArtistAlbumsCache();
_cacheService.invalidateHomeAlbumCaches();
} else if (mediaType == 'track') {
_cacheService.invalidateAllAlbumTracksCaches();
_cacheService.invalidateAllPlaylistTracksCaches();
} else if (mediaType == 'artist') {
_cacheService.invalidateHomeArtistCaches();
}
try {
await _api!.removeItemFromLibrary(mediaType, libraryItemId);
return true;
} catch (e) {
final errorStr = e.toString().toLowerCase();
// "not found in library" means item is already removed - treat as success
if (errorStr.contains('not found in library')) {
_logger.log('ℹ️ Item already removed from library');
return true;
}
// On actual error, we'd ideally restore the item, but that's complex
// For now, just log the error - the background refresh will fix state
_logger.log('❌ Failed to remove from library (local cache already updated): $e');
_scheduleLibraryRefresh(mediaType);
return false;
}
} else {
_logger.log('❌ Cannot remove from library while offline');
return false;
}
}
/// Remove item from local library cache for instant UI feedback
/// Creates new list instances to ensure Selector widgets detect changes
void _removeFromLocalLibrary(String mediaType, int libraryItemId) {
final libraryIdStr = libraryItemId.toString();
bool updated = false;
// Helper to check if item matches the library ID being removed
// Checks both direct provider=library match AND providerMappings
bool matchesLibraryId(String? provider, String? itemId, List<ProviderMapping>? mappings) {
// Direct match: item's own provider is 'library' and ID matches
if (provider == 'library' && itemId == libraryIdStr) {
return true;
}
// Mapping match: check if any providerMapping has library instance with matching ID
if (mappings != null) {
for (final m in mappings) {
if ((m.providerInstance == 'library' || m.providerDomain == 'library') &&
m.itemId == libraryIdStr) {
return true;
}
}
}
return false;
}
if (mediaType == 'artist') {
final before = _artists.length;
// Create new list to trigger Selector rebuilds (reference equality)
_artists = _artists.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _artists.length != before;
} else if (mediaType == 'album') {
final before = _albums.length;
_albums = _albums.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _albums.length != before;
} else if (mediaType == 'track') {
final before = _tracks.length;
_tracks = _tracks.where((t) =>
!matchesLibraryId(t.provider, t.itemId, t.providerMappings)
).toList();
updated = _tracks.length != before;
} else if (mediaType == 'radio') {
final before = _radioStations.length;
_radioStations = _radioStations.where((r) =>
!matchesLibraryId(r.provider, r.itemId, r.providerMappings)
).toList();
updated = _radioStations.length != before;
} else if (mediaType == 'podcast') {
final before = _podcasts.length;
_podcasts = _podcasts.where((p) =>
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
).toList();
updated = _podcasts.length != before;
}
if (updated) {
_logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from local cache');
notifyListeners();
}
}
/// Refresh library data for a media type after library change
/// Runs immediately (no delay) to ensure UI stays in sync
void _scheduleLibraryRefresh(String mediaType) {
// Run immediately - no delay to ensure data consistency
() async {
if (!isConnected || _api == null) return;
try {
if (mediaType == 'artist') {
_artists = await _api!.getArtists(
limit: LibraryConstants.maxLibraryItems,
albumArtistsOnly: false,
);
} else if (mediaType == 'album') {
_albums = await _api!.getAlbums(limit: LibraryConstants.maxLibraryItems);
} else if (mediaType == 'track') {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
} else if (mediaType == 'radio') {
_radioStations = await _api!.getRadioStations(limit: 100);
} else if (mediaType == 'podcast') {
_podcasts = await _api!.getPodcasts(limit: 100);
}
notifyListeners();
} catch (e) {
_logger.log('⚠️ Background library refresh failed: $e');
}
}();
}
Future<void> disconnect() async {
_playerStateTimer?.cancel();
_playerStateTimer = null;
@@ -966,6 +1422,8 @@ class MusicAssistantProvider with ChangeNotifier {
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_playerAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription?.cancel();
_positionTracker.clear();
// Disconnect Sendspin and PCM player if connected
if (_sendspinConnected) {
@@ -1802,6 +2260,30 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Handle media_item_added event - refresh library when items are added
/// This handles the case where addToLibrary API call times out but the item was actually added
void _handleMediaItemAddedEvent(Map<String, dynamic> event) {
try {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
} catch (e) {
_logger.log('Error handling media item added event: $e');
}
}
/// Handle media_item_deleted event - refresh library when items are removed
/// This ensures UI updates even if removeFromLibrary API call times out
void _handleMediaItemDeletedEvent(Map<String, dynamic> event) {
try {
final mediaType = event['media_type'] as String?;
_cacheService.invalidateSearchCache();
_scheduleLibraryRefresh(mediaType ?? 'artist');
} catch (e) {
_logger.log('Error handling media item deleted event: $e');
}
}
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
@@ -1911,6 +2393,44 @@ class MusicAssistantProvider with ChangeNotifier {
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
var artistName = currentMedia['artist'] as String?;
// Check if this is a radio stream
final isRadioStream = uri != null && (uri.contains('library://radio/') || uri.contains('/radio/'));
// For radio streams, log the currentMedia to debug metadata structure
if (isRadioStream) {
_logger.log('📻 Radio currentMedia keys: ${currentMedia.keys.toList()}');
_logger.log('📻 Radio currentMedia: $currentMedia');
}
// For radio streams, try additional artist sources if primary is missing
if (isRadioStream && (artistName == null || artistName.isEmpty)) {
// Check for artists array (like in Track.fromJson)
final artistsData = currentMedia['artists'];
if (artistsData is List && artistsData.isNotEmpty) {
final firstArtist = artistsData.first;
if (firstArtist is Map<String, dynamic>) {
artistName = firstArtist['name'] as String?;
} else if (firstArtist is String) {
artistName = firstArtist;
}
_logger.log('📻 Found artist from artists array: $artistName');
}
// Check for stream_title which might contain "Artist - Title" format
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
// If trackTitle is still the default or matches the stream_title, update it too
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
trackTitle = parts.sublist(1).join(' - ').trim();
}
_logger.log('📻 Parsed artist from stream_title: $artistName, title: $trackTitle');
}
}
}
if ((artistName == null || artistName == 'Unknown Artist') && trackTitle.contains(' - ')) {
final parts = trackTitle.split(' - ');
if (parts.length >= 2) {
@@ -1940,15 +2460,22 @@ class MusicAssistantProvider with ChangeNotifier {
final newHasImage = metadata != null;
final newHasAlbum = albumName != null;
final existingHasAlbum = existingTrack?.album != null;
final newHasProperArtist = artistName != 'Unknown Artist';
// For podcasts, album info is crucial (it's the podcast name)
// If new track has album but existing doesn't, prefer new or merge
final isPodcastUri = uri != null && (uri.contains('podcast_episode') || uri.contains('podcast/'));
// For radio streams, always update when we have new metadata (song changes frequently)
// This ensures radio artist/title changes are reflected immediately
final isRadioUri = uri != null && (uri.contains('library://radio/') || uri.contains('/radio/'));
// Keep existing if it has proper artist OR has image that new one lacks
// BUT for podcasts, if new has album and existing doesn't, we need that album data
// AND for radio, always update when new track has proper artist (song changed)
final keepExisting = (existingHasProperArtist || (existingHasImage && !newHasImage))
&& !(isPodcastUri && newHasAlbum && !existingHasAlbum);
&& !(isPodcastUri && newHasAlbum && !existingHasAlbum)
&& !(isRadioUri && newHasProperArtist);
if (!keepExisting) {
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
@@ -2005,11 +2532,50 @@ class MusicAssistantProvider with ChangeNotifier {
if (currentMedia == null) return;
final title = currentMedia['title'] as String? ?? 'Unknown Track';
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
var title = currentMedia['title'] as String? ?? 'Unknown Track';
var artist = currentMedia['artist'] as String?;
final album = currentMedia['album'] as String?;
var imageUrl = currentMedia['image_url'] as String?;
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final notificationUri = currentMedia['uri'] as String?;
final isRadioNotification = notificationUri != null &&
(notificationUri.contains('library://radio/') || notificationUri.contains('/radio/'));
// For radio streams, try to extract artist from various sources
if (isRadioNotification && (artist == null || artist.isEmpty)) {
// Check for artists array
final artistsData = currentMedia['artists'];
if (artistsData is List && artistsData.isNotEmpty) {
final firstArtist = artistsData.first;
if (firstArtist is Map<String, dynamic>) {
artist = firstArtist['name'] as String?;
} else if (firstArtist is String) {
artist = firstArtist;
}
}
// Check for stream_title with "Artist - Title" format
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
if (title == 'Unknown Track' || title == streamTitle) {
title = parts.sublist(1).join(' - ').trim();
}
}
}
}
// Parse artist from title if still missing
if ((artist == null || artist == 'Unknown Artist') && title.contains(' - ')) {
final parts = title.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
title = parts.sublist(1).join(' - ').trim();
}
}
artist ??= 'Unknown Artist';
if (imageUrl != null && _serverUrl != null) {
try {
@@ -2091,16 +2657,19 @@ class MusicAssistantProvider with ChangeNotifier {
try {
_logger.log('🔄 Fetching fresh recent albums from MA...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedRecentAlbums(albums);
return albums;
// Apply provider filtering
final filtered = filterByProvider(albums);
_cacheService.setCachedRecentAlbums(filtered);
return filtered;
} catch (e) {
_logger.log('❌ Failed to fetch recent albums: $e');
// Fallback on error: try memory cache, then local database
final cached = _cacheService.getCachedRecentAlbums();
if (cached != null && cached.isNotEmpty) return cached;
return RecentlyPlayedService.instance.getRecentAlbums(
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
final local = await RecentlyPlayedService.instance.getRecentAlbums(
limit: LibraryConstants.defaultRecentLimit,
);
return filterByProvider(local);
}
}
@@ -2219,7 +2788,7 @@ class MusicAssistantProvider with ChangeNotifier {
try {
_logger.log('📚 Fetching in-progress audiobooks...');
final allAudiobooks = await _api!.getAudiobooks();
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
// Filter to only those with progress, sorted by most recent/highest progress
final inProgress = allAudiobooks
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
@@ -2239,7 +2808,7 @@ class MusicAssistantProvider with ChangeNotifier {
try {
_logger.log('📚 Fetching discover audiobooks...');
final allAudiobooks = await _api!.getAudiobooks();
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
// Shuffle and take a subset
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
@@ -2384,11 +2953,12 @@ class MusicAssistantProvider with ChangeNotifier {
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
return _cacheService.getCachedSearchResults(cacheKey)!;
return filterSearchResults(_cacheService.getCachedSearchResults(cacheKey)!);
}
if (_api == null) {
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return filterSearchResults(cached);
}
try {
@@ -2405,10 +2975,11 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
_logger.log('✅ Cached search results for "$query"');
return cachedResults;
return filterSearchResults(cachedResults);
} catch (e) {
_logger.log('❌ Search failed: $e');
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return filterSearchResults(cached);
}
}
@@ -3738,8 +4309,6 @@ class MusicAssistantProvider with ChangeNotifier {
notifyListeners();
_radioStations = await _api!.getRadioStations(limit: 100);
_logger.log('📻 Loaded ${_radioStations.length} radio stations');
_isLoadingRadio = false;
notifyListeners();
} catch (e) {
@@ -3811,6 +4380,7 @@ class MusicAssistantProvider with ChangeNotifier {
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
);
_isLoading = false;
@@ -3870,13 +4440,38 @@ class MusicAssistantProvider with ChangeNotifier {
}
try {
return await _api!.search(query, libraryOnly: libraryOnly);
final results = await _api!.search(query, libraryOnly: libraryOnly);
return filterSearchResults(results);
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
/// Get playlists with provider filtering applied
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly}) async {
if (_api == null) return [];
try {
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly);
return filterByProvider(playlists);
} catch (e) {
_logger.log('❌ Failed to fetch playlists: $e');
return [];
}
}
/// Get audiobooks with provider filtering applied
Future<List<Audiobook>> getAudiobooks({int? limit, bool? favoriteOnly}) async {
if (_api == null) return [];
try {
final audiobooks = await _api!.getAudiobooks(limit: limit, favoriteOnly: favoriteOnly ?? false);
return filterByProvider(audiobooks);
} catch (e) {
_logger.log('❌ Failed to fetch audiobooks: $e');
return [];
}
}
String getStreamUrl(String provider, String itemId, {String? uri, List<ProviderMapping>? providerMappings}) {
return _api?.getStreamUrl(provider, itemId, uri: uri, providerMappings: providerMappings) ?? '';
}
@@ -4674,6 +5269,8 @@ class MusicAssistantProvider with ChangeNotifier {
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_playerAddedEventSubscription?.cancel();
_mediaItemAddedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription?.cancel();
_positionTracker.dispose();
_pcmAudioPlayer?.dispose();
_sendspinService?.dispose();
+170 -31
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;
}
@@ -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,
),
),
),
@@ -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),
+185 -2
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;
@@ -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),
+5 -2
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,
@@ -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(
+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,
+1 -1
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)
+176 -152
View File
@@ -114,6 +114,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final ScrollController _authorsScrollController = ScrollController();
final ScrollController _audiobooksScrollController = ScrollController();
final ScrollController _seriesScrollController = ScrollController();
final ScrollController _podcastsScrollController = ScrollController();
final ScrollController _radioScrollController = ScrollController();
int get _tabCount {
switch (_selectedMediaType) {
@@ -585,33 +587,27 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_authorsScrollController.dispose();
_audiobooksScrollController.dispose();
_seriesScrollController.dispose();
_podcastsScrollController.dispose();
_radioScrollController.dispose();
super.dispose();
}
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final playlists = await maProvider.api!.getPlaylists(
limit: 100,
favoriteOnly: favoriteOnly,
);
if (mounted) {
// PERF: Pre-sort once on load, not on every build
final sorted = List<Playlist>.from(playlists)
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
setState(() {
_playlists = playlists;
_sortedPlaylists = sorted;
_playlistNames = sorted.map((p) => p.name ?? '').toList();
_isLoadingPlaylists = false;
});
}
} else {
if (mounted) {
setState(() {
_isLoadingPlaylists = false;
});
}
final playlists = await maProvider.getPlaylists(
limit: 100,
favoriteOnly: favoriteOnly,
);
if (mounted) {
// PERF: Pre-sort once on load, not on every build
final sorted = List<Playlist>.from(playlists)
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
setState(() {
_playlists = playlists;
_sortedPlaylists = sorted;
_playlistNames = sorted.map((p) => p.name ?? '').toList();
_isLoadingPlaylists = false;
});
}
}
@@ -665,48 +661,39 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
_logger.log('📚 Calling API getAudiobooks...');
final audiobooks = await maProvider.api!.getAudiobooks(
limit: 10000, // Large limit to get all audiobooks
favoriteOnly: favoriteOnly,
);
_logger.log('📚 API returned ${audiobooks.length} audiobooks');
if (audiobooks.isNotEmpty) {
_logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}');
}
if (mounted) {
// PERF: Pre-sort and pre-group once on load, not on every build
final sortedAlpha = List<Audiobook>.from(audiobooks)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_logger.log('📚 Calling getAudiobooks...');
final audiobooks = await maProvider.getAudiobooks(
limit: 10000, // Large limit to get all audiobooks
favoriteOnly: favoriteOnly,
);
_logger.log('📚 Returned ${audiobooks.length} audiobooks');
if (audiobooks.isNotEmpty) {
_logger.log('📚 First audiobook: ${audiobooks.first.name} by ${audiobooks.first.authorsString}');
}
if (mounted) {
// PERF: Pre-sort and pre-group once on load, not on every build
final sortedAlpha = List<Audiobook>.from(audiobooks)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// Group audiobooks by author
final authorMap = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
authorMap.putIfAbsent(authorName, () => []).add(book);
}
final sortedAuthors = authorMap.keys.toList()..sort();
// Group audiobooks by author
final authorMap = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
authorMap.putIfAbsent(authorName, () => []).add(book);
}
final sortedAuthors = authorMap.keys.toList()..sort();
setState(() {
_audiobooks = audiobooks;
_sortedAudiobooks = sortedAlpha;
_audiobookNames = sortedAlpha.map((a) => a.name).toList();
_groupedAudiobooksByAuthor = authorMap;
_sortedAuthorNames = sortedAuthors;
_isLoadingAudiobooks = false;
});
_logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}');
// Fetch author images in background
_fetchAuthorImages(audiobooks);
}
} else {
_logger.log('📚 API is null!');
if (mounted) {
setState(() {
_isLoadingAudiobooks = false;
});
}
setState(() {
_audiobooks = audiobooks;
_sortedAudiobooks = sortedAlpha;
_audiobookNames = sortedAlpha.map((a) => a.name).toList();
_groupedAudiobooksByAuthor = authorMap;
_sortedAuthorNames = sortedAuthors;
_isLoadingAudiobooks = false;
});
_logger.log('📚 State updated, _audiobooks.length = ${_audiobooks.length}');
// Fetch author images in background
_fetchAuthorImages(audiobooks);
}
}
@@ -1014,7 +1001,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
end: Alignment.bottomCenter,
colors: [
colorScheme.background,
colorScheme.background.withOpacity(0),
colorScheme.surface.withOpacity(0),
],
),
),
@@ -1156,7 +1143,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
width: 36,
height: 36,
child: Material(
color: _showFavoritesOnly ? Colors.red : colorScheme.surface,
color: _showFavoritesOnly ? Colors.red : colorScheme.background,
elevation: 2,
shadowColor: Colors.black26,
shape: const CircleBorder(),
@@ -1177,7 +1164,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
width: 36,
height: 36,
child: Material(
color: colorScheme.surface,
color: colorScheme.background,
elevation: 2,
shadowColor: Colors.black26,
shape: const CircleBorder(),
@@ -1330,7 +1317,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Match music artists tab layout - no header, direct list/grid
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar(
controller: _authorsScrollController,
@@ -1393,6 +1380,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: 48,
height: 48,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Text(
@@ -1478,6 +1467,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: size,
height: size,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
@@ -1568,6 +1559,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
@@ -1667,7 +1660,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Match music albums tab layout - no header, direct list/grid
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () => _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar(
controller: _audiobooksScrollController,
@@ -1736,6 +1729,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 512,
memCacheHeight: 512,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
@@ -1955,6 +1950,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: firstCover,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => Icon(
Icons.collections_bookmark_rounded,
color: colorScheme.onSurfaceVariant,
@@ -2269,12 +2266,20 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Use consistent 256 for all views to improve hero animation smoothness (matches detail screen)
const cacheSize = 256;
// Generate podcast names for letter scrollbar
final podcastNames = podcasts.map((p) => p.name).toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () => maProvider.loadPodcasts(),
child: _podcastsViewMode == 'list'
child: LetterScrollbar(
controller: _podcastsScrollController,
items: podcastNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _podcastsViewMode == 'list'
? ListView.builder(
controller: _podcastsScrollController,
key: const PageStorageKey<String>('podcasts_list'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2301,7 +2306,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
width: 56,
height: 56,
fit: BoxFit.cover,
// No memCacheWidth - decode at native resolution for smooth hero
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
@@ -2340,6 +2347,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
},
)
: GridView.builder(
controller: _podcastsScrollController,
key: PageStorageKey<String>('podcasts_grid_$_podcastsViewMode'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2355,6 +2363,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return _buildPodcastCard(podcast, maProvider, cacheSize);
},
),
),
);
}
@@ -2382,7 +2391,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// No memCacheWidth - decode at native resolution for smooth hero
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
@@ -2484,12 +2495,20 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Use appropriate cache size based on view mode
final cacheSize = _radioViewMode == 'grid3' ? 200 : 256;
// Generate radio station names for letter scrollbar
final radioNames = radioStations.map((s) => s.name).toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () => maProvider.loadRadioStations(),
child: _radioViewMode == 'list'
child: LetterScrollbar(
controller: _radioScrollController,
items: radioNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _radioViewMode == 'list'
? ListView.builder(
controller: _radioScrollController,
key: const PageStorageKey<String>('radio_stations_list'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2555,6 +2574,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
},
)
: GridView.builder(
controller: _radioScrollController,
key: PageStorageKey<String>('radio_stations_grid_$_radioViewMode'),
cacheExtent: 1000,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.withMiniPlayer),
@@ -2570,6 +2590,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return _buildRadioCard(station, maProvider, cacheSize);
},
),
),
);
}
@@ -2642,89 +2663,90 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
// Artist filtering (albumArtistsOnly) is now done at API level in library_provider
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading),
builder: (context, data, _) {
final (allArtists, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme;
final (allArtists, isLoading) = data;
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
if (isLoading) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
}
// Filter by favorites if enabled
final artists = _showFavoritesOnly
? allArtists.where((a) => a.favorite == true).toList()
: allArtists;
// Filter by favorites if enabled (artist filter is done at API level)
final artists = _showFavoritesOnly
? allArtists.where((a) => a.favorite == true).toList()
: allArtists;
if (artists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteArtists,
subtitle: l10n.tapHeartArtist,
if (artists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: l10n.noFavoriteArtists,
subtitle: l10n.tapHeartArtist,
);
}
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// Sort artists alphabetically for letter scrollbar
final sortedArtists = List<Artist>.from(artists)
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _artistsScrollController,
items: artistNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _artistsViewMode == 'list'
? ListView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedArtists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistTile(
context,
artist,
key: ValueKey(artist.uri ?? artist.itemId),
);
},
)
: GridView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedArtists.length,
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistGridCard(context, artist);
},
),
),
);
}
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
// Sort artists alphabetically for letter scrollbar
final sortedArtists = List<Artist>.from(artists)
..sort((a, b) => (a.name ?? '').compareTo(b.name ?? ''));
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _artistsScrollController,
items: artistNames,
onDragStateChanged: _onLetterScrollbarDragChanged,
child: _artistsViewMode == 'list'
? ListView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: sortedArtists.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 16, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistTile(
context,
artist,
key: ValueKey(artist.uri ?? artist.itemId),
);
},
)
: GridView.builder(
controller: _artistsScrollController,
key: PageStorageKey<String>('library_artists_grid_${_showFavoritesOnly ? 'fav' : 'all'}_$_artistsViewMode'),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _artistsViewMode == 'grid3' ? 3 : 2,
childAspectRatio: _artistsViewMode == 'grid3' ? 0.75 : 0.80,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: sortedArtists.length,
itemBuilder: (context, index) {
final artist = sortedArtists[index];
return _buildArtistGridCard(context, artist);
},
),
),
);
},
},
);
}
@@ -2879,7 +2901,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
child: LetterScrollbar(
controller: _albumsScrollController,
@@ -2949,6 +2971,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
@@ -3016,7 +3040,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Use pre-sorted lists (sorted once on load)
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
child: LetterScrollbar(
controller: _playlistsScrollController,
@@ -3215,7 +3239,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Use pre-sorted list (sorted once on load)
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: _loadFavoriteTracks,
child: ListView.builder(
key: const PageStorageKey<String>('library_tracks_list'),
+190 -6
View File
@@ -35,6 +35,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
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}' : '';
@@ -42,6 +43,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
@override
void initState() {
super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast);
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
@@ -52,6 +54,110 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
});
}
/// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.podcast.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.podcast.provider != 'library') {
actualProvider = widget.podcast.provider;
actualItemId = widget.podcast.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: podcast is library-only');
return;
}
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling podcast library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
@@ -249,11 +355,14 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
// CRITICAL: Use initialImageUrl for hero animation consistency
// The library passes the same URL it uses for display, ensuring cache hit
// Only fall back to provider URL if initialImageUrl is null
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
// 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,
@@ -325,6 +434,9 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
? 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(
@@ -383,6 +495,78 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
],
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(
@@ -483,7 +667,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = maProvider.getImageUrl(episode, size: 256);
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
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
// ============================================
+144 -101
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 = {
@@ -528,7 +522,6 @@ class MusicAssistantAPI {
/// Search for radio stations globally (across all providers like TuneIn)
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
try {
_logger.log('📻 Searching radio stations for: $query');
final response = await _sendCommand(
'music/search',
args: {
@@ -539,23 +532,18 @@ class MusicAssistantAPI {
);
final result = response['result'] as Map<String, dynamic>?;
if (result == null) {
_logger.log('📻 Radio search: no result');
return [];
}
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>?) ??
[];
_logger.log('📻 Radio search found ${radios.length} results');
return radios
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('📻 Error searching radio stations: $e');
_logger.log('Error searching radio stations: $e');
return [];
}
}
@@ -569,7 +557,6 @@ class MusicAssistantAPI {
bool? favoriteOnly,
}) async {
try {
_logger.log('🎙️ Getting podcasts...');
final response = await _sendCommand(
'music/podcasts/library_items',
args: {
@@ -580,18 +567,13 @@ class MusicAssistantAPI {
);
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('🎙️ Podcasts: result is null');
return [];
}
_logger.log('🎙️ Found ${items.length} podcasts');
if (items == null) return [];
return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('🎙️ Error getting podcasts: $e');
_logger.log('Error getting podcasts: $e');
return [];
}
}
@@ -603,9 +585,6 @@ class MusicAssistantAPI {
int? offset,
}) async {
try {
_logger.log('🎙️ Getting episodes for podcast: $podcastId, provider: $provider');
// Use the correct endpoint: music/podcasts/podcast_episodes
final response = await _sendCommand(
'music/podcasts/podcast_episodes',
args: {
@@ -614,21 +593,16 @@ class MusicAssistantAPI {
},
);
_logger.log('🎙️ Response keys: ${response.keys.toList()}');
// Response should be a list of episodes
final episodes = response['result'] as List<dynamic>?;
if (episodes != null && episodes.isNotEmpty) {
_logger.log('🎙️ Found ${episodes.length} episodes');
return episodes
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
}
_logger.log('🎙️ No episodes found for podcast');
return [];
} catch (e) {
_logger.log('🎙️ Error getting podcast episodes: $e');
_logger.log('Error getting podcast episodes: $e');
return [];
}
}
@@ -648,7 +622,6 @@ class MusicAssistantAPI {
// No podcast image, try to get first episode's image
try {
_logger.log('🎙️ No podcast cover, fetching episode cover for: ${podcast.name}');
final episodes = await getPodcastEpisodes(
podcast.itemId,
provider: podcast.provider,
@@ -658,12 +631,11 @@ class MusicAssistantAPI {
final firstEpisode = episodes.first;
final episodeImage = getImageUrl(firstEpisode, size: size);
if (episodeImage != null) {
_logger.log('🎙️ Using episode cover for podcast: ${podcast.name}');
return episodeImage;
}
}
} catch (e) {
_logger.log('🎙️ Error fetching episode cover: $e');
// Fall through to return podcast image
}
// Fall back to podcast image even if it might be low quality
@@ -727,10 +699,6 @@ class MusicAssistantAPI {
uri = 'library://podcast_episode/${episode.itemId}';
}
_logger.log('🎙️ Playing podcast episode: ${episode.name}');
_logger.log('🎙️ Episode URI: $uri');
_logger.log('🎙️ Episode itemId: ${episode.itemId}');
await _sendCommand(
'player_queues/play_media',
args: {
@@ -740,7 +708,7 @@ class MusicAssistantAPI {
},
);
} catch (e) {
_logger.log('🎙️ Error playing podcast episode: $e');
_logger.log('Error playing podcast episode: $e');
rethrow;
}
}
@@ -758,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);
}
@@ -771,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,
@@ -780,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');
@@ -1318,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 [];
@@ -1327,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);
}
@@ -1356,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;
@@ -1630,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
@@ -1948,6 +1895,86 @@ class MusicAssistantAPI {
}
}
/// Play album on player
Future<void> playAlbum(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Playing album: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing album: $e');
rethrow;
}
}
/// Add album tracks to queue
Future<void> addAlbumToQueue(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Adding album to queue: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding album to queue: $e');
rethrow;
}
}
/// Add artist tracks to queue
Future<void> addArtistToQueue(String playerId, Artist artist) async {
try {
final uri = artist.uri ?? 'library://artist/${artist.itemId}';
_logger.log('🎤 Adding artist to queue: ${artist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding artist to queue: $e');
rethrow;
}
}
/// Play playlist on player
Future<void> playPlaylist(String playerId, Playlist playlist) async {
try {
final uri = playlist.uri ?? 'library://playlist/${playlist.itemId}';
_logger.log('📝 Playing playlist: ${playlist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing playlist: $e');
rethrow;
}
}
/// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued
@@ -2453,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
+12
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
@@ -489,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();
+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);
+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
+117 -31
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;
@@ -1158,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;
@@ -1197,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)
@@ -1302,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;
@@ -1501,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,
@@ -1764,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(
@@ -1893,18 +1901,70 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
top: controlsTop,
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
? _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
// 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
@@ -2335,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,
@@ -2349,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(
@@ -2369,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(
@@ -2385,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(
@@ -2393,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,
),
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.8.0-beta+37
version: 2.8.3-beta+40
environment:
sdk: '>=3.0.0 <4.0.0'