Compare commits

...
Author SHA1 Message Date
ChrisandClaude Opus 4.5 1f3a01c22f Bump version to 2.9.3-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:20:19 +00:00
ChrisandClaude Opus 4.5 27e1b8b746 Fix local player track data disappearing when selected
When selecting the builtin/local player from the player selector overlay,
the track info would disappear because the queue API returns null for
local players. The code was clearing _currentTrack unconditionally.

Now for the builtin player:
- Preserve cached track data instead of clearing it
- Restore from cache if _currentTrack is null
- Update notification with actual track info when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 9be3737a9d Add centralized LibraryStatusService for consistent library/favorite state
- Create LibraryStatusService singleton for single source of truth
- Add LibraryStatusMixin for reactive status in StatefulWidgets
- Convert all card widgets to use centralized service:
  - album_card, artist_card, playlist_card, podcast_card
  - radio_station_card, track_row, audiobook_row
- Convert detail screens: album_details_screen, artist_details_screen
- Fix album stuck in library after removal:
  - Add removeFromCacheByLibraryId() to SyncService
  - Add markCachedItemDeletedByLibraryId() to DatabaseService
  - Update removeFromLibrary() to sync all caches

This ensures library/favorite status changes are immediately reflected
across all instances of the same item throughout the app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 882e8b2ea4 Simplify player selection logic
Remove confusing auto-switch behavior that could change the selected
player unexpectedly right before the user taps play.

Removed settings:
- "Prefer Local Player"
- "Disable Auto-Switch"

New simplified logic:
1. User's manual selection is always respected
2. On app reopen: restore last selected player
3. If selected player goes offline: switch to local player
4. First launch (no history): select local player
5. Local player unavailable: select first available

Kept "Smart Sort" setting for display order only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 aef9423b42 Improve adaptive color contrast using HCT color space
- Replace HSL-based color adjustments with HCT (Material Design 3 standard)
- Use proper WCAG contrast ratio calculations instead of luminance threshold
- Implement binary search to find minimum tone adjustment for contrast
- Ensure minimum chroma of 36 for primary colors to prevent washed-out highlights
- Add color scheme caching for instant loading on revisits
- Pre-extract colors on album tap for faster initial load

Fixes issue where highlighted library button was darker than unhighlighted
state on albums with dark/muted covers (e.g., Portishead, NIHT).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
Chris c0138828a7 Revise reverse proxy details and remove auth methods
Updated reverse proxy options and removed authentication section.
2026-01-27 08:01:56 +00:00
19 changed files with 1489 additions and 671 deletions
+1 -15
View File
@@ -127,25 +127,11 @@ To find the correct URL:
If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
Ensemble supports multiple authentication methods:
| Method | Status |
|--------|--------|
| Music Assistant native auth | Tested |
| No authentication | Tested |
| Authelia | Implemented, not recently tested |
| HTTP Basic Auth | Implemented, not recently tested |
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
## Requirements
+118 -106
View File
@@ -26,6 +26,7 @@ import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../services/library_status_service.dart';
import '../main.dart' show audioHandler;
/// Main provider that coordinates connection, player, and library state.
@@ -64,8 +65,6 @@ class MusicAssistantProvider with ChangeNotifier {
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
@@ -922,6 +921,7 @@ class MusicAssistantProvider with ChangeNotifier {
_artists = syncService.cachedArtists;
// Note: tracks are loaded separately via API, not cached in SyncService
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
}
} catch (e) {
@@ -1623,6 +1623,41 @@ class MusicAssistantProvider with ChangeNotifier {
// LIBRARY MANAGEMENT
// ============================================================================
/// Sync library status to centralized service for reactive UI updates
void _syncLibraryStatusToService() {
final service = LibraryStatusService.instance;
// Sync albums
service.syncFromLibrary(
mediaType: 'album',
items: _albums,
getInLibrary: (a) => (a as Album).inLibrary,
getFavorite: (a) => (a as Album).favorite ?? false,
getProvider: (a) => (a as Album).provider,
getItemId: (a) => (a as Album).itemId,
);
// Sync artists
service.syncFromLibrary(
mediaType: 'artist',
items: _artists,
getInLibrary: (a) => (a as Artist).inLibrary,
getFavorite: (a) => (a as Artist).favorite ?? false,
getProvider: (a) => (a as Artist).provider,
getItemId: (a) => (a as Artist).itemId,
);
// Sync tracks
service.syncFromLibrary(
mediaType: 'track',
items: _tracks,
getInLibrary: (t) => (t as Track).inLibrary,
getFavorite: (t) => (t as Track).favorite ?? false,
getProvider: (t) => (t as Track).provider,
getItemId: (t) => (t as Track).itemId,
);
}
/// Add item to library
/// Returns true if action was executed successfully
Future<bool> addToLibrary({
@@ -1668,8 +1703,11 @@ class MusicAssistantProvider with ChangeNotifier {
// 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 remove from SyncService's in-memory cache
SyncService.instance.removeFromCacheByLibraryId(mediaType, libraryItemId);
// Also mark as deleted in database cache so it doesn't reappear on next load
DatabaseService.instance.markCachedItemDeleted(mediaType, libraryItemId.toString());
// Use the library ID aware method that handles cases where itemId differs from libraryId
DatabaseService.instance.markCachedItemDeletedByLibraryId(mediaType, libraryItemId.toString());
// Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache();
if (mediaType == 'album') {
@@ -1790,6 +1828,7 @@ class MusicAssistantProvider with ChangeNotifier {
} else if (mediaType == 'podcast') {
_podcasts = await _api!.getPodcasts(limit: 100);
}
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
_logger.log('⚠️ Background library refresh failed: $e');
@@ -3175,6 +3214,7 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('⚠️ Failed to refresh tracks: $e');
}
_syncLibraryStatusToService();
notifyListeners();
_logger.log('✅ Force sync complete');
}
@@ -3793,33 +3833,20 @@ class MusicAssistantProvider with ChangeNotifier {
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Log settings for debugging player selection issues
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId, coldStart=$coldStart');
// If "Prefer Local Player" is ON, always try to select local player first
// This takes priority even over the currently selected player
if (preferLocalPlayer && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
} catch (e) {
// Local player not available yet, will fall through to other options
_logger.log('⚠️ Prefer local player ON but local player not available');
}
}
// SIMPLIFIED PLAYER SELECTION LOGIC:
// 1. User's manual selection is always respected
// 2. On app reopen: restore last selection
// 3. If selected player unavailable: switch to local player
// 4. First launch (no history): select local player
// 5. Local player unavailable: select first available
// Keep currently selected player if still available (and not overridden by prefer local)
// On coldStart, skip this block and apply full priority logic
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
// Keep currently selected player if still available
if (_selectedPlayer != null && !coldStart) {
// Check if selected player is still available - also check translated Cast/Sendspin IDs
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
final selectedId = _selectedPlayer!.playerId;
final translatedId = _castToSendspinIdMap[selectedId];
String? reverseTranslatedId;
@@ -3836,103 +3863,55 @@ class MusicAssistantProvider with ChangeNotifier {
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
);
if (stillAvailable) {
// Find the actual player in the list (might be Cast or Sendspin version)
final currentPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == selectedId ||
(translatedId != null && p.playerId == translatedId) ||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
);
// Keep current selection - no auto-switching during non-coldStart refreshes
// Auto-switch to playing player is now ONLY done on coldStart (see below)
playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
} else {
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
}
} else {
// Selected player went offline - will fall through to local player
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer available');
}
}
if (coldStart) {
_logger.log('🚀 Cold start: applying full priority logic');
// Priority 1: Restore last selected player (on coldStart)
if (playerToSelect == null && coldStart && lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
}
}
if (playerToSelect == null) {
// Smart auto-selection priority (coldStart only for auto-switch to playing):
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
// Priority 3: Last manually selected player
// Priority 4: First available player
// Priority 1: Auto-switch to single playing player
// ONLY on coldStart AND only if disableAutoSwitch is OFF
// Also apply cooldown to prevent rapid successive auto-switches
final cooldownActive = _lastPlayerSwitchTime != null &&
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
if (playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
} else if (playingPlayers.length > 1) {
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
}
} else if (cooldownActive) {
_logger.log('⏳ Auto-switch cooldown active, skipping');
} else if (!coldStart) {
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
} else if (disableAutoSwitch) {
_logger.log('⚠️ Auto-switch disabled by user setting');
}
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {
// Local player not found or not available - check why
final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId)
.toList();
if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
// Priority 3: Last manually selected player
if (playerToSelect == null && lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}');
}
}
// Priority 4: First available player
if (playerToSelect == null) {
// Priority 2: Local player (default fallback)
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
_logger.log('📱 Selected local player: ${playerToSelect.name}');
} catch (e) {
_logger.log('⚠️ Local player not available');
}
}
// Priority 3: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
selectPlayer(playerToSelect);
}
@@ -4715,19 +4694,32 @@ class MusicAssistantProvider with ChangeNotifier {
);
}
} else {
// No track data available - show player name placeholder for active players
// No queue data available from API
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
if (_currentTrack != null) {
// For builtin player, try to preserve cached track data since queue API doesn't return it
// The cached track was set in selectPlayer() and contains valid data
if (isBuiltinPlayer && _currentTrack == null) {
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null) {
_currentTrack = cachedTrack;
stateChanged = true;
_logger.log('🎵 Restored cached track for builtin player: ${cachedTrack.name}');
}
}
// Only clear track if we have no cached data to fall back on
// Don't clear for builtin player - it maintains its own playback state
if (_currentTrack != null && !isBuiltinPlayer) {
_currentTrack = null;
stateChanged = true;
// Persist cleared track state
_persistPlaybackState();
}
// Show player name placeholder so notification shows correct player
if (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused') {
// Show player name placeholder only if we have no track data
if (_currentTrack == null && (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused')) {
final mediaItem = audio_service.MediaItem(
id: 'player_${_selectedPlayer!.playerId}',
title: _selectedPlayer!.name,
@@ -4749,7 +4741,22 @@ class MusicAssistantProvider with ChangeNotifier {
duration: Duration.zero,
);
}
} else {
} else if (_currentTrack != null && isBuiltinPlayer) {
// Builtin player has track data - update notification with it
final track = _currentTrack!;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
duration: track.duration,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
);
} else if (_selectedPlayer!.state != 'playing' && _selectedPlayer!.state != 'paused') {
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
_positionTracker.clear();
@@ -4843,6 +4850,7 @@ class MusicAssistantProvider with ChangeNotifier {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
_syncLibraryStatusToService();
notifyListeners();
}
@@ -4860,6 +4868,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
// Trigger background sync (non-blocking)
@@ -4886,6 +4895,7 @@ class MusicAssistantProvider with ChangeNotifier {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
}
syncService.removeListener(onSyncComplete);
@@ -4983,6 +4993,7 @@ class MusicAssistantProvider with ChangeNotifier {
SyncService.instance.updateCachedArtists(_artists);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
@@ -5018,6 +5029,7 @@ class MusicAssistantProvider with ChangeNotifier {
SyncService.instance.updateCachedAlbums(_albums);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
+65 -50
View File
@@ -11,10 +11,12 @@ import '../theme/theme_provider.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../services/library_status_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/library_status_builder.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
@@ -36,12 +38,10 @@ class AlbumDetailsScreen extends StatefulWidget {
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
}
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin {
final _logger = DebugLogger();
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
@@ -53,11 +53,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_loadTracks();
_loadAlbumDescription();
@@ -97,8 +111,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (freshAlbum != null && mounted) {
setState(() {
_freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
});
// Sync fresh data to centralized service
final service = LibraryStatusService.instance;
service.syncSingleItem(
key: libraryItemKey,
inLibrary: freshAlbum.inLibrary,
favorite: freshAlbum.favorite ?? false,
);
// Re-extract colors now that we have fresh album with images
_extractColors();
}
@@ -134,9 +154,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
final newState = !_isFavorite;
bool success;
if (newState) {
@@ -182,6 +206,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this album');
}
@@ -192,11 +217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
if (success) {
// Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
completeFavoriteOperation();
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
@@ -207,14 +228,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -230,10 +254,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
final newState = !_isInLibrary;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
@@ -263,10 +287,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -278,20 +300,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
// Fire and forget - API call happens in background
maProvider.addToLibrary(
final success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
_logger.log('❌ Failed to add album to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
// Remove from library
int? libraryItemId;
@@ -312,10 +331,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return;
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -326,21 +343,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
final success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove album from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling album library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1019,8 +1034,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary
: Colors.white70,
),
@@ -1044,7 +1059,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Icon(
Icons.favorite,
size: 25,
color: _isFavorite
color: isFavorite
? colorScheme.error
: Colors.white70,
),
@@ -1068,8 +1083,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
position: position,
mediaType: ContextMenuMediaType.album,
item: widget.album,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
+70 -58
View File
@@ -18,6 +18,8 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import '../services/library_status_service.dart';
import '../widgets/library_status_builder.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -35,13 +37,11 @@ class ArtistDetailsScreen extends StatefulWidget {
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
}
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryStatusMixin {
final _logger = DebugLogger();
List<Album> _albums = [];
List<Album> _providerAlbums = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
@@ -49,6 +49,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
String? _artistImageUrl;
MusicAssistantProvider? _maProvider;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'artist',
widget.artist.provider,
widget.artist.itemId,
);
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
@@ -58,8 +65,15 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
@override
void initState() {
super.initState();
_isFavorite = widget.artist.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.artist);
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && _checkIfInLibrary(widget.artist)) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadViewPreferences();
@@ -96,12 +110,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void _onProviderChanged() {
if (!mounted) return;
// Re-check library status when provider data changes
// Re-check library status when provider data changes and sync to service
final newIsInLibrary = _checkIfInLibraryFromProvider();
if (newIsInLibrary != _isInLibrary) {
setState(() {
_isInLibrary = newIsInLibrary;
});
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (newIsInLibrary != service.isInLibrary(key)) {
service.setLibraryStatus(key, newIsInLibrary);
}
}
@@ -214,9 +228,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
try {
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
if (freshArtist != null && mounted) {
setState(() {
_isFavorite = freshArtist.favorite ?? false;
});
// Sync with centralized service
final service = LibraryStatusService.instance;
final key = libraryItemKey;
final newFavorite = freshArtist.favorite ?? false;
if (service.isFavorite(key) != newFavorite) {
service.setFavoriteStatus(key, newFavorite);
}
}
} catch (e) {
_logger.log('Error refreshing artist favorite status: $e');
@@ -225,9 +243,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
final newState = !_isFavorite;
bool success;
if (newState) {
@@ -270,6 +292,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this artist');
}
@@ -280,10 +303,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
if (success) {
setState(() {
_isFavorite = newState;
});
completeFavoriteOperation();
maProvider.invalidateHomeCache();
if (mounted) {
@@ -293,14 +313,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling artist favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -322,10 +345,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
final newState = !_isInLibrary;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
@@ -355,10 +378,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -370,21 +391,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
// Fire and forget - API call happens in background
maProvider.addToLibrary(
final success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
_logger.log('❌ Failed to add artist to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
return false;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
// Remove from library
int? libraryItemId;
@@ -405,10 +422,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return;
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -419,22 +434,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
final success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove artist from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
return false;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling artist library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1009,8 +1021,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary
: Colors.white70,
),
@@ -1034,7 +1046,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Icon(
Icons.favorite,
size: 25,
color: _isFavorite
color: isFavorite
? colorScheme.error
: Colors.white70,
),
@@ -1057,8 +1069,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
position: position,
mediaType: ContextMenuMediaType.artist,
item: widget.artist,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
+1 -59
View File
@@ -40,9 +40,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
@@ -86,9 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final rowOrder = await SettingsService.getHomeRowOrder();
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
@@ -112,9 +108,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
@@ -703,33 +697,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
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(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -754,32 +721,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.disableAutoSwitch,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.disableAutoSwitchDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _disableAutoSwitch,
onChanged: (value) {
setState(() => _disableAutoSwitch = value);
SettingsService.setDisableAutoSwitch(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
+27
View File
@@ -193,6 +193,33 @@ class DatabaseService {
return db.markItemsDeleted(itemType, [itemId]);
}
/// Mark a cached item as deleted by searching for library ID in the data
/// This handles cases where itemId is different from library ID
Future<void> markCachedItemDeletedByLibraryId(String itemType, String libraryId) async {
// First try direct match (for provider=library items)
await db.markItemsDeleted(itemType, [libraryId]);
// Also search through cached items to find any with matching library mapping
final items = await db.getCachedItems(itemType);
for (final item in items) {
try {
// Check if this item's data contains a library mapping with this ID
if (item.data.contains('"provider_instance":"library"') &&
item.data.contains('"item_id":"$libraryId"')) {
// Found a match - extract the actual cache key's itemId
final cacheKey = item.cacheKey;
final parts = cacheKey.split('_');
if (parts.length >= 2) {
final actualItemId = parts.sublist(1).join('_');
await db.markItemsDeleted(itemType, [actualItemId]);
}
}
} catch (_) {
// Ignore parse errors, continue checking other items
}
}
}
/// Batch cache multiple items efficiently
/// Uses drift's batch API for much better performance on large datasets.
/// Items are automatically chunked into batches of 500.
+177
View File
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Centralized service for tracking library and favorite status of media items.
///
/// This service implements the "single source of truth" pattern:
/// - All widgets query this service instead of maintaining local state
/// - Status changes notify all listeners immediately (optimistic updates)
/// - API failures trigger rollback notifications
///
/// Usage:
/// ```dart
/// // Check status
/// final isInLibrary = LibraryStatusService.instance.isInLibrary(itemKey);
///
/// // Listen to changes
/// LibraryStatusService.instance.addListener(() {
/// // Rebuild UI
/// });
///
/// // Update status (optimistically)
/// LibraryStatusService.instance.setLibraryStatus(itemKey, true);
/// ```
class LibraryStatusService extends ChangeNotifier {
LibraryStatusService._();
static final LibraryStatusService instance = LibraryStatusService._();
// Status maps: itemKey -> status
// itemKey format: "mediaType:provider:itemId" for uniqueness
final Map<String, bool> _libraryStatus = {};
final Map<String, bool> _favoriteStatus = {};
// Pending operations for rollback on failure
final Map<String, bool> _pendingLibraryOps = {};
final Map<String, bool> _pendingFavoriteOps = {};
/// Generate a unique key for an item
static String makeKey(String mediaType, String provider, String itemId) {
return '$mediaType:$provider:$itemId';
}
/// Check if item is in library
bool isInLibrary(String key) => _libraryStatus[key] ?? false;
/// Check if item is favorite
bool isFavorite(String key) => _favoriteStatus[key] ?? false;
/// Set library status (optimistic update)
/// Call this immediately when user taps the button
void setLibraryStatus(String key, bool inLibrary) {
if (_libraryStatus[key] == inLibrary) return;
_libraryStatus[key] = inLibrary;
notifyListeners();
}
/// Set favorite status (optimistic update)
void setFavoriteStatus(String key, bool favorite) {
if (_favoriteStatus[key] == favorite) return;
_favoriteStatus[key] = favorite;
notifyListeners();
}
/// Mark operation as pending (for rollback tracking)
void markLibraryPending(String key, bool previousValue) {
_pendingLibraryOps[key] = previousValue;
}
void markFavoritePending(String key, bool previousValue) {
_pendingFavoriteOps[key] = previousValue;
}
/// Complete operation successfully (clear pending)
void completeLibraryOperation(String key) {
_pendingLibraryOps.remove(key);
}
void completeFavoriteOperation(String key) {
_pendingFavoriteOps.remove(key);
}
/// Rollback operation on failure
void rollbackLibraryOperation(String key) {
final previousValue = _pendingLibraryOps.remove(key);
if (previousValue != null) {
_libraryStatus[key] = previousValue;
notifyListeners();
}
}
void rollbackFavoriteOperation(String key) {
final previousValue = _pendingFavoriteOps.remove(key);
if (previousValue != null) {
_favoriteStatus[key] = previousValue;
notifyListeners();
}
}
/// Bulk update from provider's library data
/// Called when library is loaded/refreshed
void syncFromLibrary({
required String mediaType,
required List<dynamic> items,
required bool Function(dynamic item) getInLibrary,
required bool Function(dynamic item) getFavorite,
required String Function(dynamic item) getProvider,
required String Function(dynamic item) getItemId,
}) {
bool changed = false;
for (final item in items) {
final key = makeKey(mediaType, getProvider(item), getItemId(item));
final inLibrary = getInLibrary(item);
final favorite = getFavorite(item);
// Only update if not pending (don't overwrite optimistic updates)
if (!_pendingLibraryOps.containsKey(key)) {
if (_libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
}
if (!_pendingFavoriteOps.containsKey(key)) {
if (_favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
}
}
if (changed) {
notifyListeners();
}
}
/// Update a single item's status from fresh data
void syncSingleItem({
required String key,
required bool inLibrary,
required bool favorite,
}) {
bool changed = false;
if (!_pendingLibraryOps.containsKey(key) && _libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
if (!_pendingFavoriteOps.containsKey(key) && _favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
if (changed) {
notifyListeners();
}
}
/// Clear all status (on logout/disconnect)
void clear() {
_libraryStatus.clear();
_favoriteStatus.clear();
_pendingLibraryOps.clear();
_pendingFavoriteOps.clear();
notifyListeners();
}
/// Get debug info
Map<String, dynamic> get debugInfo => {
'libraryCount': _libraryStatus.length,
'favoriteCount': _favoriteStatus.length,
'pendingLibrary': _pendingLibraryOps.length,
'pendingFavorite': _pendingFavoriteOps.length,
};
}
-10
View File
@@ -90,9 +90,7 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyPreferLocalPlayer = 'prefer_local_player';
static const String _keySmartSortPlayers = 'smart_sort_players';
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
@@ -355,18 +353,10 @@ class SettingsService {
static Future<void> setLastSelectedPlayerId(String? playerId) =>
_setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true);
// Prefer Local Player - always select local player first when available
static Future<bool> getPreferLocalPlayer() => _getBool(_keyPreferLocalPlayer, defaultValue: false);
static Future<void> setPreferLocalPlayer(bool prefer) => _setBool(_keyPreferLocalPlayer, prefer);
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
// Disable Auto-Switch - prevents automatic switching to playing players
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
// Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) {
+74
View File
@@ -596,4 +596,78 @@ class SyncService with ChangeNotifier {
_cachedPlaylists = playlists;
notifyListeners();
}
/// Remove an item from in-memory cache by library ID
/// Called when an item is removed from library to keep SyncService in sync
void removeFromCacheByLibraryId(String mediaType, int libraryItemId) {
final libraryIdStr = libraryItemId.toString();
bool updated = false;
// Helper to check if item matches the library ID being removed
bool matchesLibraryId(String? provider, String? itemId, List<ProviderMapping>? mappings) {
if (provider == 'library' && itemId == libraryIdStr) return true;
if (mappings != null) {
for (final m in mappings) {
if ((m.providerInstance == 'library' || m.providerDomain == 'library') &&
m.itemId == libraryIdStr) {
return true;
}
}
}
return false;
}
switch (mediaType) {
case 'album':
final before = _cachedAlbums.length;
_cachedAlbums = _cachedAlbums.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedAlbums.length != before;
// Also remove from source provider tracking
if (updated) {
_albumSourceProviders.removeWhere((key, _) =>
_cachedAlbums.every((a) => a.itemId != key));
}
break;
case 'artist':
final before = _cachedArtists.length;
_cachedArtists = _cachedArtists.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedArtists.length != before;
if (updated) {
_artistSourceProviders.removeWhere((key, _) =>
_cachedArtists.every((a) => a.itemId != key));
}
break;
case 'audiobook':
final before = _cachedAudiobooks.length;
_cachedAudiobooks = _cachedAudiobooks.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedAudiobooks.length != before;
if (updated) {
_audiobookSourceProviders.removeWhere((key, _) =>
_cachedAudiobooks.every((a) => a.itemId != key));
}
break;
case 'playlist':
final before = _cachedPlaylists.length;
_cachedPlaylists = _cachedPlaylists.where((p) =>
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
).toList();
updated = _cachedPlaylists.length != before;
if (updated) {
_playlistSourceProviders.removeWhere((key, _) =>
_cachedPlaylists.every((p) => p.itemId != key));
}
break;
}
if (updated) {
_logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from SyncService cache');
notifyListeners();
}
}
}
+252 -114
View File
@@ -1,6 +1,8 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:image/image.dart' as img;
import 'package:http/http.dart' as http;
@@ -8,6 +10,16 @@ import '../services/debug_logger.dart';
final _logger = DebugLogger();
/// Minimum contrast ratio for UI components (WCAG 2.1 Level AA)
const double _minContrastRatio = 3.0;
/// Minimum chroma for primary colors to ensure they're visually distinct
/// Material Design 3 uses 48 for vibrant, but we use 36 as a balance
const double _minPrimaryChroma = 36.0;
/// Minimum chroma for surface/container colors (lower is fine for backgrounds)
const double _minSurfaceChroma = 6.0;
/// Extracted adaptive colors from album art
class AdaptiveColors {
final Color primary; // Main accent color (vibrant)
@@ -50,11 +62,96 @@ class _ExtractedColors {
});
}
/// Helper to get contrasting text color based on background luminance
/// Calculate WCAG 2.1 contrast ratio between two colors
double _contrastRatio(Color c1, Color c2) {
final l1 = c1.computeLuminance();
final l2 = c2.computeLuminance();
final lighter = math.max(l1, l2);
final darker = math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/// Helper to get contrasting text color based on WCAG contrast ratio
Color getContrastingTextColor(Color backgroundColor) {
// Use relative luminance to determine if we need light or dark text
// Standard threshold is 0.179 based on WCAG guidelines
return backgroundColor.computeLuminance() > 0.4 ? Colors.black : Colors.white;
// Calculate contrast with both black and white, pick the one with better contrast
final contrastWithWhite = _contrastRatio(backgroundColor, Colors.white);
final contrastWithBlack = _contrastRatio(backgroundColor, Colors.black);
return contrastWithWhite >= contrastWithBlack ? Colors.white : Colors.black;
}
/// Adjust a color's tone using HCT to ensure minimum contrast against a background
/// Uses binary search to find the MINIMUM tone adjustment needed (preserves color identity)
Color _ensureContrastHct(Color foreground, Color background, {double minRatio = _minContrastRatio}) {
final fgHct = Hct.fromInt(foreground.value);
final bgHct = Hct.fromInt(background.value);
// Ensure foreground has enough chroma to be visually distinct
final chroma = math.max(fgHct.chroma, _minPrimaryChroma);
final currentRatio = _contrastRatio(foreground, background);
if (currentRatio >= minRatio) {
// Already has sufficient contrast, just ensure chroma
if (fgHct.chroma < _minPrimaryChroma) {
return Color(Hct.from(fgHct.hue, chroma, fgHct.tone).toInt());
}
return foreground;
}
// For dark backgrounds (tone < 50), we need lighter foreground
// For light backgrounds (tone >= 50), we need darker foreground
final bgIsDark = bgHct.tone < 50;
// Binary search for the minimum tone that achieves target contrast
double lo, hi;
if (bgIsDark) {
// Search upward from current tone toward white
lo = fgHct.tone;
hi = 100.0;
} else {
// Search downward from current tone toward black
lo = 0.0;
hi = fgHct.tone;
}
double bestTone = bgIsDark ? hi : lo; // Fallback to extreme if needed
for (int i = 0; i < 20; i++) {
final mid = (lo + hi) / 2;
final testColor = Hct.from(fgHct.hue, chroma, mid);
final testRatio = _contrastRatio(Color(testColor.toInt()), background);
if (bgIsDark) {
// We want lighter - if mid works, try lower (closer to original)
if (testRatio >= minRatio) {
bestTone = mid;
hi = mid; // Try to find a lower tone that still works
} else {
lo = mid; // Need to go lighter
}
} else {
// We want darker - if mid works, try higher (closer to original)
if (testRatio >= minRatio) {
bestTone = mid;
lo = mid; // Try to find a higher tone that still works
} else {
hi = mid; // Need to go darker
}
}
if (hi - lo < 0.5) break; // Precision reached
}
return Color(Hct.from(fgHct.hue, chroma, bestTone).toInt());
}
/// Ensure a color has minimum chroma (colorfulness) for primary use
Color _ensureMinChroma(Color color) {
final hct = Hct.fromInt(color.value);
if (hct.chroma >= _minPrimaryChroma) {
return color;
}
final adjusted = Hct.from(hct.hue, _minPrimaryChroma, hct.tone);
return Color(adjusted.toInt());
}
/// Top-level function for isolate-based color extraction
@@ -156,11 +253,22 @@ class PaletteHelper {
static final Map<String, Uint8List> _imageCache = {};
static const int _maxCacheSize = 20;
/// Cache for extracted color schemes to avoid re-processing
static final Map<String, (ColorScheme, ColorScheme)> _colorSchemeCache = {};
static const int _maxColorSchemeCacheSize = 30;
/// Extract color schemes from a URL using isolate-based processing
/// This is the preferred method as it doesn't block the main thread
/// Results are cached for instant retrieval on subsequent calls
static Future<(ColorScheme, ColorScheme)?> extractColorSchemesFromUrl(String imageUrl) async {
try {
// Check cache first
// Check color scheme cache first for instant return
final cachedSchemes = _colorSchemeCache[imageUrl];
if (cachedSchemes != null) {
return cachedSchemes;
}
// Check image bytes cache
Uint8List? imageBytes = _imageCache[imageUrl];
if (imageBytes == null) {
@@ -184,14 +292,22 @@ class PaletteHelper {
if (extractedColors == null) return null;
// Convert to ColorSchemes on main thread
return _buildColorSchemes(extractedColors);
final colorSchemes = _buildColorSchemes(extractedColors);
// Cache the color schemes for instant access next time
if (_colorSchemeCache.length >= _maxColorSchemeCacheSize) {
_colorSchemeCache.remove(_colorSchemeCache.keys.first);
}
_colorSchemeCache[imageUrl] = colorSchemes;
return colorSchemes;
} catch (e) {
_logger.warning('Failed to extract colors from URL: $e', context: 'Palette');
return null;
}
}
/// Build ColorSchemes from extracted color data
/// Build ColorSchemes from extracted color data using HCT color space
static (ColorScheme, ColorScheme) _buildColorSchemes(_ExtractedColors colors) {
// Convert int colors to Color objects
final vibrant = Color(colors.vibrantColor);
@@ -201,69 +317,85 @@ class PaletteHelper {
final muted = Color(colors.mutedColor);
final lightMuted = Color(colors.lightMutedColor);
// Get primary color - prefer vibrant, then dominant
// Get primary color - prefer vibrant, then lightVibrant, then dominant
Color primary = vibrant;
if (vibrant.computeLuminance() < 0.1) {
final vibrantHct = Hct.fromInt(vibrant.value);
if (vibrantHct.tone < 20 || vibrantHct.chroma < 8) {
primary = lightVibrant;
}
if (primary.computeLuminance() < 0.1) {
final primaryHct = Hct.fromInt(primary.value);
if (primaryHct.tone < 20 || primaryHct.chroma < 8) {
primary = dominant;
}
// Ensure primary has enough saturation
final hslPrimary = HSLColor.fromColor(primary);
Color adjustedPrimary = primary;
if (hslPrimary.saturation < 0.3) {
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
}
if (adjustedPrimary.computeLuminance() < 0.15) {
final hsl = HSLColor.fromColor(adjustedPrimary);
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
}
// Use HCT for perceptually accurate color adjustment
// Ensure minimum chroma (colorfulness) so colors aren't muddy
Color adjustedPrimary = _ensureMinChroma(primary);
// Build dark mode colors
final hslDarkSurface = HSLColor.fromColor(darkMuted);
final darkSurface = hslDarkSurface
.withLightness((hslDarkSurface.lightness * 0.3).clamp(0.05, 0.15))
.toColor();
final darkMiniPlayer = hslDarkSurface
.withLightness(0.3.clamp(0.25, 0.38))
.withSaturation((hslDarkSurface.saturation * 1.2).clamp(0.15, 0.5))
.toColor();
// Build dark mode colors using HCT
final darkMutedHct = Hct.fromInt(darkMuted.value);
final darkSurface = Color(Hct.from(
darkMutedHct.hue,
math.min(darkMutedHct.chroma, _minSurfaceChroma), // Low chroma for surface
math.max(darkMutedHct.tone * 0.3, 5.0).clamp(5.0, 15.0), // Very dark
).toInt());
final darkMiniPlayer = Color(Hct.from(
darkMutedHct.hue,
(darkMutedHct.chroma * 1.2).clamp(4, 16),
30, // Fixed tone for consistency
).toInt());
// Build light mode colors
final hslLightSurface = HSLColor.fromColor(lightMuted);
final lightSurface = hslLightSurface
.withLightness(hslLightSurface.lightness.clamp(0.92, 0.98))
.toColor();
final lightMiniPlayer = hslLightSurface
.withLightness(0.75.clamp(0.65, 0.8))
.withSaturation((hslLightSurface.saturation * 1.2).clamp(0.15, 0.5))
.toColor();
// Build light mode colors using HCT
final lightMutedHct = Hct.fromInt(lightMuted.value);
final lightSurface = Color(Hct.from(
lightMutedHct.hue,
math.min(lightMutedHct.chroma, _minSurfaceChroma),
95, // Very light
).toInt());
final lightMiniPlayer = Color(Hct.from(
lightMutedHct.hue,
(lightMutedHct.chroma * 1.2).clamp(4, 16),
75,
).toInt());
// Generate secondary container colors using HCT
// Dark secondary container at tone 25 for tonal buttons
final primaryHctFinal = Hct.fromInt(adjustedPrimary.value);
final darkSecondaryContainer = Color(Hct.from(
primaryHctFinal.hue,
(primaryHctFinal.chroma * 0.4).clamp(4, 16),
25, // Fixed tone for dark mode tonal buttons
).toInt());
final lightSecondaryContainer = Color(Hct.from(
primaryHctFinal.hue,
(primaryHctFinal.chroma * 0.3).clamp(4, 12),
90, // Fixed tone for light mode tonal buttons
).toInt());
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
// This guarantees highlighted icons are visible on tonal buttons
final darkAdjustedPrimary = _ensureContrastHct(
adjustedPrimary,
darkSecondaryContainer,
minRatio: _minContrastRatio,
);
final lightAdjustedPrimary = _ensureContrastHct(
adjustedPrimary,
lightSecondaryContainer,
minRatio: _minContrastRatio,
);
// Determine contrasting text colors
final lightOnPrimary = getContrastingTextColor(adjustedPrimary);
final darkOnPrimary = getContrastingTextColor(adjustedPrimary);
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
final lightOnMiniPlayer = getContrastingTextColor(lightMiniPlayer);
final darkOnMiniPlayer = getContrastingTextColor(darkMiniPlayer);
// Generate secondary container colors for FilledButton.tonal
// These must be explicitly set or Flutter auto-generates them unpredictably
final hslForSecondary = HSLColor.fromColor(adjustedPrimary);
final lightSecondaryContainer = hslForSecondary
.withLightness(0.9)
.withSaturation((hslForSecondary.saturation * 0.5).clamp(0.1, 0.3))
.toColor();
final darkSecondaryContainer = hslForSecondary
.withLightness(0.25)
.withSaturation((hslForSecondary.saturation * 0.6).clamp(0.15, 0.4))
.toColor();
final lightScheme = ColorScheme(
brightness: Brightness.light,
primary: adjustedPrimary,
primary: lightAdjustedPrimary,
onPrimary: lightOnPrimary,
secondary: adjustedPrimary.withOpacity(0.8),
secondary: lightAdjustedPrimary.withOpacity(0.8),
onSecondary: lightOnPrimary,
secondaryContainer: lightSecondaryContainer,
onSecondaryContainer: Colors.black87,
@@ -277,9 +409,9 @@ class PaletteHelper {
final darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: adjustedPrimary,
primary: darkAdjustedPrimary,
onPrimary: darkOnPrimary,
secondary: adjustedPrimary.withOpacity(0.8),
secondary: darkAdjustedPrimary.withOpacity(0.8),
onSecondary: darkOnPrimary,
secondaryContainer: darkSecondaryContainer,
onSecondaryContainer: Colors.white,
@@ -294,9 +426,10 @@ class PaletteHelper {
return (lightScheme, darkScheme);
}
/// Clear the image cache
/// Clear all caches (image bytes and color schemes)
static void clearCache() {
_imageCache.clear();
_colorSchemeCache.clear();
}
/// Extract a color palette from an image with higher color count for better variety
@@ -314,7 +447,7 @@ class PaletteHelper {
}
}
/// Extract actual colors from the palette (not seed-based)
/// Extract actual colors from the palette using HCT color space
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
if (palette == null) return null;
@@ -324,22 +457,9 @@ class PaletteHelper {
palette.dominantColor?.color ??
const Color(0xFF604CEC);
// Ensure primary has enough saturation to be visually distinct
// AND enough lightness for white text to be readable on it
final HSLColor hslPrimary = HSLColor.fromColor(primary);
Color adjustedPrimary = primary;
// Adjust saturation if too low
if (hslPrimary.saturation < 0.3) {
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
}
// Ensure minimum lightness for contrast with white text
// If primary is too dark (luminance < 0.15), lighten it
if (adjustedPrimary.computeLuminance() < 0.15) {
final hsl = HSLColor.fromColor(adjustedPrimary);
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
}
// Use HCT for perceptually accurate color adjustment
// Ensure minimum chroma (colorfulness) so colors aren't muddy
Color adjustedPrimary = _ensureMinChroma(primary);
if (isDark) {
// Dark mode: use dark muted colors for background
@@ -347,18 +467,19 @@ class PaletteHelper {
palette.mutedColor?.color ??
const Color(0xFF121212);
// Darken the surface color significantly for the expanded background
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
final Color surface = hslSurface
.withLightness((hslSurface.lightness * 0.3).clamp(0.05, 0.15))
.toColor();
// Build surface colors using HCT
final surfaceHct = Hct.fromInt(surfaceBase.value);
final Color surface = Color(Hct.from(
surfaceHct.hue,
math.min(surfaceHct.chroma, _minSurfaceChroma),
math.max(surfaceHct.tone * 0.3, 5.0).clamp(5.0, 15.0),
).toInt());
// Mini player should be medium brightness - noticeably tinted but readable
// Use the muted color but at medium lightness (0.25-0.35 range)
final Color miniPlayer = hslSurface
.withLightness(0.3.clamp(0.25, 0.38))
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
.toColor();
final Color miniPlayer = Color(Hct.from(
surfaceHct.hue,
(surfaceHct.chroma * 1.2).clamp(4, 16),
30,
).toInt());
return AdaptiveColors(
primary: adjustedPrimary,
@@ -372,16 +493,18 @@ class PaletteHelper {
palette.mutedColor?.color ??
Colors.white;
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
final Color surface = hslSurface
.withLightness((hslSurface.lightness).clamp(0.92, 0.98))
.toColor();
final surfaceHct = Hct.fromInt(surfaceBase.value);
final Color surface = Color(Hct.from(
surfaceHct.hue,
math.min(surfaceHct.chroma, _minSurfaceChroma),
95,
).toInt());
// Mini player in light mode - medium tinted
final Color miniPlayer = hslSurface
.withLightness(0.75.clamp(0.65, 0.8))
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
.toColor();
final Color miniPlayer = Color(Hct.from(
surfaceHct.hue,
(surfaceHct.chroma * 1.2).clamp(4, 16),
75,
).toInt());
return AdaptiveColors(
primary: adjustedPrimary,
@@ -392,7 +515,7 @@ class PaletteHelper {
}
}
/// Generate color schemes from a palette (kept for backward compatibility)
/// Generate color schemes from a palette using HCT color space
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
if (palette == null) return null;
@@ -401,29 +524,44 @@ class PaletteHelper {
if (lightColors == null || darkColors == null) return null;
// Determine contrasting text colors based on primary color luminance
final lightOnPrimary = getContrastingTextColor(lightColors.primary);
final darkOnPrimary = getContrastingTextColor(darkColors.primary);
// Generate secondary container colors using HCT
final lightPrimaryHct = Hct.fromInt(lightColors.primary.value);
final lightSecondaryContainer = Color(Hct.from(
lightPrimaryHct.hue,
(lightPrimaryHct.chroma * 0.3).clamp(4, 12),
90, // Fixed tone for light mode tonal buttons
).toInt());
final darkPrimaryHct = Hct.fromInt(darkColors.primary.value);
final darkSecondaryContainer = Color(Hct.from(
darkPrimaryHct.hue,
(darkPrimaryHct.chroma * 0.4).clamp(4, 16),
25, // Fixed tone for dark mode tonal buttons
).toInt());
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
final lightAdjustedPrimary = _ensureContrastHct(
lightColors.primary,
lightSecondaryContainer,
minRatio: _minContrastRatio,
);
final darkAdjustedPrimary = _ensureContrastHct(
darkColors.primary,
darkSecondaryContainer,
minRatio: _minContrastRatio,
);
// Determine contrasting text colors
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer);
// Generate secondary container colors for FilledButton.tonal
final hslLightPrimary = HSLColor.fromColor(lightColors.primary);
final lightSecondaryContainer = hslLightPrimary
.withLightness(0.9)
.withSaturation((hslLightPrimary.saturation * 0.5).clamp(0.1, 0.3))
.toColor();
final hslDarkPrimary = HSLColor.fromColor(darkColors.primary);
final darkSecondaryContainer = hslDarkPrimary
.withLightness(0.25)
.withSaturation((hslDarkPrimary.saturation * 0.6).clamp(0.15, 0.4))
.toColor();
final lightScheme = ColorScheme(
brightness: Brightness.light,
primary: lightColors.primary,
primary: lightAdjustedPrimary,
onPrimary: lightOnPrimary,
secondary: lightColors.primary.withOpacity(0.8),
secondary: lightAdjustedPrimary.withOpacity(0.8),
onSecondary: lightOnPrimary,
secondaryContainer: lightSecondaryContainer,
onSecondaryContainer: Colors.black87,
@@ -437,9 +575,9 @@ class PaletteHelper {
final darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: darkColors.primary,
primary: darkAdjustedPrimary,
onPrimary: darkOnPrimary,
secondary: darkColors.primary.withOpacity(0.8),
secondary: darkAdjustedPrimary.withOpacity(0.8),
onSecondary: darkOnPrimary,
secondaryContainer: darkSecondaryContainer,
onSecondaryContainer: Colors.white,
+83 -38
View File
@@ -11,9 +11,11 @@ import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class AlbumCard extends StatefulWidget {
final Album album;
@@ -35,25 +37,37 @@ class AlbumCard extends StatefulWidget {
State<AlbumCard> createState() => _AlbumCardState();
}
class _AlbumCardState extends State<AlbumCard> {
class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
String? _fallbackImageUrl;
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
bool _isNavigating = false;
late bool _isFavorite;
late bool _isInLibrary;
/// Delay before fetching fallback images to avoid requests during fast scroll
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_initFallbackImage();
}
@@ -111,7 +125,11 @@ class _AlbumCardState extends State<AlbumCard> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
@@ -150,7 +168,10 @@ class _AlbumCardState extends State<AlbumCard> {
}
}
if (libraryItemId == null) return;
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'album',
@@ -159,23 +180,26 @@ class _AlbumCardState extends State<AlbumCard> {
}
if (success && mounted) {
setState(() => _isFavorite = newState);
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
// Silent failure
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
@@ -202,21 +226,29 @@ class _AlbumCardState extends State<AlbumCard> {
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.addToLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.album.provider == 'library') {
@@ -233,23 +265,31 @@ class _AlbumCardState extends State<AlbumCard> {
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.removeFromLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
// Silent failure
rollbackLibraryOperation();
}
}
@@ -276,8 +316,13 @@ class _AlbumCardState extends State<AlbumCard> {
if (_isNavigating) return;
_isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
// Start color extraction immediately (fire-and-forget)
// Runs in isolate so won't block Hero animation
// Results are cached, so detail screen gets them instantly
if (imageUrl != null) {
updateAdaptiveColorsFromImage(context, imageUrl);
}
Navigator.push(
context,
FadeSlidePageRoute(
@@ -300,8 +345,8 @@ class _AlbumCardState extends State<AlbumCard> {
position: details.globalPosition,
mediaType: ContextMenuMediaType.album,
item: widget.album,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
+76 -38
View File
@@ -11,8 +11,10 @@ import '../constants/timings.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class ArtistCard extends StatefulWidget {
final Artist artist;
@@ -34,7 +36,7 @@ class ArtistCard extends StatefulWidget {
State<ArtistCard> createState() => _ArtistCardState();
}
class _ArtistCardState extends State<ArtistCard> {
class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
static final _logger = DebugLogger();
String? _fallbackImageUrl;
bool _triedFallback = false;
@@ -42,18 +44,30 @@ class _ArtistCardState extends State<ArtistCard> {
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
bool _isNavigating = false;
late bool _isFavorite;
late bool _isInLibrary;
/// Delay before fetching fallback images to avoid requests during fast scroll
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'artist',
widget.artist.provider,
widget.artist.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.artist.favorite ?? false;
_isInLibrary = widget.artist.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Fetch fallback image once in initState, not during build
_initFallbackImage();
}
@@ -102,7 +116,11 @@ class _ArtistCardState extends State<ArtistCard> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
@@ -141,7 +159,10 @@ class _ArtistCardState extends State<ArtistCard> {
}
}
if (libraryItemId == null) return;
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
@@ -150,23 +171,26 @@ class _ArtistCardState extends State<ArtistCard> {
}
if (success && mounted) {
setState(() => _isFavorite = newState);
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
// Silent failure
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
@@ -193,22 +217,29 @@ class _ArtistCardState extends State<ArtistCard> {
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.addToLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
return false;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.artist.provider == 'library') {
@@ -225,24 +256,31 @@ class _ArtistCardState extends State<ArtistCard> {
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.removeFromLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
return false;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
// Silent failure
rollbackLibraryOperation();
}
}
@@ -293,8 +331,8 @@ class _ArtistCardState extends State<ArtistCard> {
position: details.globalPosition,
mediaType: ContextMenuMediaType.artist,
item: widget.artist,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
+75 -36
View File
@@ -10,8 +10,10 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../screens/audiobook_detail_screen.dart';
import '../constants/hero_tags.dart';
import '../services/library_status_service.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class AudiobookRow extends StatefulWidget {
final String title;
@@ -206,20 +208,35 @@ class _AudiobookCard extends StatefulWidget {
State<_AudiobookCard> createState() => _AudiobookCardState();
}
class _AudiobookCardState extends State<_AudiobookCard> {
late bool _isFavorite;
late bool _isInLibrary;
class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin {
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'audiobook',
widget.audiobook.provider,
widget.audiobook.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = widget.audiobook.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.audiobook.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
@@ -258,7 +275,10 @@ class _AudiobookCardState extends State<_AudiobookCard> {
}
}
if (libraryItemId == null) return;
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
@@ -267,23 +287,26 @@ class _AudiobookCardState extends State<_AudiobookCard> {
}
if (success && mounted) {
setState(() => _isFavorite = newState);
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
// Silent failure
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
@@ -310,21 +333,29 @@ class _AudiobookCardState extends State<_AudiobookCard> {
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.addToLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.audiobook.provider == 'library') {
@@ -341,23 +372,31 @@ class _AudiobookCardState extends State<_AudiobookCard> {
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.removeFromLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
// Silent failure
rollbackLibraryOperation();
}
}
@@ -386,8 +425,8 @@ class _AudiobookCardState extends State<_AudiobookCard> {
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import '../services/library_status_service.dart';
/// A widget that rebuilds when the library/favorite status of an item changes.
///
/// This widget listens to the centralized [LibraryStatusService] and only
/// rebuilds when the status of the specified item changes.
///
/// Example:
/// ```dart
/// LibraryStatusBuilder(
/// itemKey: LibraryStatusService.makeKey('album', album.provider, album.itemId),
/// builder: (context, isInLibrary, isFavorite) {
/// return IconButton(
/// icon: Icon(isInLibrary ? Icons.library_add_check : Icons.library_add),
/// onPressed: () => toggleLibrary(),
/// );
/// },
/// )
/// ```
class LibraryStatusBuilder extends StatefulWidget {
final String itemKey;
final Widget Function(BuildContext context, bool isInLibrary, bool isFavorite) builder;
const LibraryStatusBuilder({
super.key,
required this.itemKey,
required this.builder,
});
@override
State<LibraryStatusBuilder> createState() => _LibraryStatusBuilderState();
}
class _LibraryStatusBuilderState extends State<LibraryStatusBuilder> {
late bool _isInLibrary;
late bool _isFavorite;
@override
void initState() {
super.initState();
_updateStatus();
LibraryStatusService.instance.addListener(_onStatusChanged);
}
@override
void didUpdateWidget(LibraryStatusBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemKey != widget.itemKey) {
_updateStatus();
}
}
@override
void dispose() {
LibraryStatusService.instance.removeListener(_onStatusChanged);
super.dispose();
}
void _updateStatus() {
_isInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
_isFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
}
void _onStatusChanged() {
final newInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
final newFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
if (newInLibrary != _isInLibrary || newFavorite != _isFavorite) {
setState(() {
_isInLibrary = newInLibrary;
_isFavorite = newFavorite;
});
}
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _isInLibrary, _isFavorite);
}
}
/// Mixin for StatefulWidgets that need to track library/favorite status.
///
/// This provides a simpler API for widgets that already maintain other state.
///
/// Example:
/// ```dart
/// class _MyWidgetState extends State<MyWidget> with LibraryStatusMixin {
/// @override
/// String get libraryItemKey => LibraryStatusService.makeKey('album', widget.album.provider, widget.album.itemId);
///
/// @override
/// Widget build(BuildContext context) {
/// return IconButton(
/// icon: Icon(isInLibrary ? Icons.check : Icons.add),
/// onPressed: toggleLibrary,
/// );
/// }
/// }
/// ```
mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
/// Override to provide the item key
String get libraryItemKey;
/// Whether the item is currently in the library
bool get isInLibrary => LibraryStatusService.instance.isInLibrary(libraryItemKey);
/// Whether the item is currently a favorite
bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey);
@override
void initState() {
super.initState();
LibraryStatusService.instance.addListener(_onLibraryStatusChanged);
}
@override
void dispose() {
LibraryStatusService.instance.removeListener(_onLibraryStatusChanged);
super.dispose();
}
void _onLibraryStatusChanged() {
// Trigger rebuild to pick up new status values
if (mounted) {
setState(() {});
}
}
/// Set library status optimistically and return previous value for rollback
bool setLibraryStatus(bool value) {
final previous = isInLibrary;
LibraryStatusService.instance.markLibraryPending(libraryItemKey, previous);
LibraryStatusService.instance.setLibraryStatus(libraryItemKey, value);
return previous;
}
/// Set favorite status optimistically and return previous value for rollback
bool setFavoriteStatus(bool value) {
final previous = isFavorite;
LibraryStatusService.instance.markFavoritePending(libraryItemKey, previous);
LibraryStatusService.instance.setFavoriteStatus(libraryItemKey, value);
return previous;
}
/// Complete a successful library operation
void completeLibraryOperation() {
LibraryStatusService.instance.completeLibraryOperation(libraryItemKey);
}
/// Complete a successful favorite operation
void completeFavoriteOperation() {
LibraryStatusService.instance.completeFavoriteOperation(libraryItemKey);
}
/// Rollback a failed library operation
void rollbackLibraryOperation() {
LibraryStatusService.instance.rollbackLibraryOperation(libraryItemKey);
}
/// Rollback a failed favorite operation
void rollbackFavoriteOperation() {
LibraryStatusService.instance.rollbackFavoriteOperation(libraryItemKey);
}
}
+76 -38
View File
@@ -9,9 +9,11 @@ import '../screens/playlist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../utils/page_transitions.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class PlaylistCard extends StatefulWidget {
final Playlist playlist;
@@ -31,21 +33,37 @@ class PlaylistCard extends StatefulWidget {
State<PlaylistCard> createState() => _PlaylistCardState();
}
class _PlaylistCardState extends State<PlaylistCard> {
class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
bool _isNavigating = false;
late bool _isFavorite;
late bool _isInLibrary;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'playlist',
widget.playlist.provider,
widget.playlist.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.playlist.favorite ?? false;
_isInLibrary = widget.playlist.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.playlist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
@@ -84,7 +102,10 @@ class _PlaylistCardState extends State<PlaylistCard> {
}
}
if (libraryItemId == null) return;
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'playlist',
@@ -93,23 +114,26 @@ class _PlaylistCardState extends State<PlaylistCard> {
}
if (success && mounted) {
setState(() => _isFavorite = newState);
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
// Silent failure
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
@@ -136,22 +160,29 @@ class _PlaylistCardState extends State<PlaylistCard> {
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.addToLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'playlist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
return false;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.playlist.provider == 'library') {
@@ -168,24 +199,31 @@ class _PlaylistCardState extends State<PlaylistCard> {
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.removeFromLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'playlist',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
return false;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
// Silent failure
rollbackLibraryOperation();
}
}
@@ -230,8 +268,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
position: details.globalPosition,
mediaType: ContextMenuMediaType.playlist,
item: widget.playlist,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
+76 -36
View File
@@ -11,8 +11,10 @@ import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../services/library_status_service.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class PodcastCard extends StatefulWidget {
final MediaItem podcast;
@@ -34,21 +36,37 @@ class PodcastCard extends StatefulWidget {
State<PodcastCard> createState() => _PodcastCardState();
}
class _PodcastCardState extends State<PodcastCard> {
class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
bool _isNavigating = false;
late bool _isFavorite;
late bool _isInLibrary;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'podcast',
widget.podcast.provider,
widget.podcast.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.podcast.favorite ?? false;
_isInLibrary = widget.podcast.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.podcast.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
@@ -87,7 +105,10 @@ class _PodcastCardState extends State<PodcastCard> {
}
}
if (libraryItemId == null) return;
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'podcast',
@@ -96,23 +117,26 @@ class _PodcastCardState extends State<PodcastCard> {
}
if (success && mounted) {
setState(() => _isFavorite = newState);
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
// Silent failure
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
@@ -139,21 +163,29 @@ class _PodcastCardState extends State<PodcastCard> {
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.addToLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.podcast.provider == 'library') {
@@ -170,23 +202,31 @@ class _PodcastCardState extends State<PodcastCard> {
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.removeFromLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
// Silent failure
rollbackLibraryOperation();
}
}
@@ -234,8 +274,8 @@ class _PodcastCardState extends State<PodcastCard> {
position: details.globalPosition,
mediaType: ContextMenuMediaType.podcast,
item: widget.podcast,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
+76 -36
View File
@@ -7,8 +7,10 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class RadioStationCard extends StatefulWidget {
final MediaItem radioStation;
@@ -28,21 +30,37 @@ class RadioStationCard extends StatefulWidget {
State<RadioStationCard> createState() => _RadioStationCardState();
}
class _RadioStationCardState extends State<RadioStationCard> {
class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusMixin {
bool _isTapping = false;
late bool _isFavorite;
late bool _isInLibrary;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'radio',
widget.radioStation.provider,
widget.radioStation.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.radioStation.favorite ?? false;
_isInLibrary = widget.radioStation.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.radioStation.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
@@ -81,7 +99,10 @@ class _RadioStationCardState extends State<RadioStationCard> {
}
}
if (libraryItemId == null) return;
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'radio',
@@ -90,23 +111,26 @@ class _RadioStationCardState extends State<RadioStationCard> {
}
if (success && mounted) {
setState(() => _isFavorite = newState);
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
// Silent failure
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
@@ -133,21 +157,29 @@ class _RadioStationCardState extends State<RadioStationCard> {
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.addToLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'radio',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.radioStation.provider == 'library') {
@@ -164,23 +196,31 @@ class _RadioStationCardState extends State<RadioStationCard> {
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.removeFromLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'radio',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
// Silent failure
rollbackLibraryOperation();
}
}
@@ -219,8 +259,8 @@ class _RadioStationCardState extends State<RadioStationCard> {
position: details.globalPosition,
mediaType: ContextMenuMediaType.radio,
item: widget.radioStation,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
+75 -36
View File
@@ -6,7 +6,9 @@ import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../services/library_status_service.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class TrackRow extends StatefulWidget {
final String title;
@@ -194,20 +196,35 @@ class _TrackCard extends StatefulWidget {
State<_TrackCard> createState() => _TrackCardState();
}
class _TrackCardState extends State<_TrackCard> {
late bool _isFavorite;
late bool _isInLibrary;
class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'track',
widget.track.provider,
widget.track.itemId,
);
@override
void initState() {
super.initState();
_isFavorite = widget.track.favorite ?? false;
_isInLibrary = widget.track.inLibrary;
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.track.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.track.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
@@ -246,7 +263,10 @@ class _TrackCardState extends State<_TrackCard> {
}
}
if (libraryItemId == null) return;
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'track',
@@ -255,23 +275,26 @@ class _TrackCardState extends State<_TrackCard> {
}
if (success && mounted) {
setState(() => _isFavorite = newState);
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
// Silent failure
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
@@ -298,21 +321,29 @@ class _TrackCardState extends State<_TrackCard> {
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.addToLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.track.provider == 'library') {
@@ -329,23 +360,31 @@ class _TrackCardState extends State<_TrackCard> {
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
// Optimistic update via centralized service
setLibraryStatus(newState);
maProvider.removeFromLibrary(
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
// Silent failure
rollbackLibraryOperation();
}
}
@@ -370,8 +409,8 @@ class _TrackCardState extends State<_TrackCard> {
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: widget.track,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.9.2-beta+50
version: 2.9.3-beta+51
environment:
sdk: '>=3.0.0 <4.0.0'