Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f3a01c22f | ||
|
|
27e1b8b746 | ||
|
|
9be3737a9d | ||
|
|
882e8b2ea4 | ||
|
|
aef9423b42 | ||
|
|
c0138828a7 |
@@ -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
|
||||
|
||||
|
||||
@@ -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,94 +3863,47 @@ 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}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (coldStart) {
|
||||
_logger.log('🚀 Cold start: applying full priority logic');
|
||||
}
|
||||
|
||||
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}');
|
||||
// Selected player went offline - will fall through to local player
|
||||
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer 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) {
|
||||
// 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('🔄 Auto-selected last used player: ${playerToSelect?.name}');
|
||||
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: First available player
|
||||
// Priority 2: Local player (default fallback)
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
_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,
|
||||
@@ -3931,7 +3911,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
);
|
||||
_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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} 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;
|
||||
});
|
||||
);
|
||||
|
||||
if (success) {
|
||||
completeLibraryOperation();
|
||||
} else {
|
||||
rollbackLibraryOperation();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+71
-26
@@ -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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.addToLibrary(
|
||||
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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.addToLibrary(
|
||||
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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.addToLibrary(
|
||||
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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.addToLibrary(
|
||||
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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.addToLibrary(
|
||||
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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.addToLibrary(
|
||||
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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
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,
|
||||
);
|
||||
|
||||
+63
-24
@@ -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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.addToLibrary(
|
||||
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);
|
||||
// Optimistic update via centralized service
|
||||
setLibraryStatus(newState);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user