Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5a8631a8 | ||
|
|
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:
|
If you run Music Assistant as a Home Assistant add-on:
|
||||||
- Use the IP address of your Home Assistant server
|
- 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
|
- Do **not** use your Home Assistant URL or ingress URL
|
||||||
|
|
||||||
### Remote Access
|
### 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).
|
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).
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "com.musicassistant.music_assistant"
|
namespace "com.collotsspot.ensemble"
|
||||||
compileSdk 35
|
compileSdk 35
|
||||||
ndkVersion flutter.ndkVersion
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.musicassistant.music_assistant"
|
applicationId "com.collotsspot.ensemble"
|
||||||
minSdkVersion flutter.minSdkVersion
|
minSdkVersion flutter.minSdkVersion
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.musicassistant.music_assistant
|
package com.collotsspot.ensemble
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
// while also intercepting volume button events
|
// while also intercepting volume button events
|
||||||
class MainActivity: AudioServiceActivity() {
|
class MainActivity: AudioServiceActivity() {
|
||||||
private val TAG = "EnsembleVolume"
|
private val TAG = "EnsembleVolume"
|
||||||
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
|
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
private var isListening = false
|
private var isListening = false
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ import '../services/pcm_audio_player.dart';
|
|||||||
import '../services/offline_action_queue.dart';
|
import '../services/offline_action_queue.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import '../main.dart' show audioHandler;
|
import '../main.dart' show audioHandler;
|
||||||
|
|
||||||
/// Main provider that coordinates connection, player, and library state.
|
/// Main provider that coordinates connection, player, and library state.
|
||||||
@@ -64,8 +65,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Player? _selectedPlayer;
|
Player? _selectedPlayer;
|
||||||
List<Player> _availablePlayers = [];
|
List<Player> _availablePlayers = [];
|
||||||
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
|
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
|
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
||||||
Track? _currentTrack;
|
Track? _currentTrack;
|
||||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||||
@@ -922,6 +921,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_artists = syncService.cachedArtists;
|
_artists = syncService.cachedArtists;
|
||||||
// Note: tracks are loaded separately via API, not cached in SyncService
|
// Note: tracks are loaded separately via API, not cached in SyncService
|
||||||
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
|
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1623,6 +1623,41 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// LIBRARY MANAGEMENT
|
// 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
|
/// Add item to library
|
||||||
/// Returns true if action was executed successfully
|
/// Returns true if action was executed successfully
|
||||||
Future<bool> addToLibrary({
|
Future<bool> addToLibrary({
|
||||||
@@ -1668,8 +1703,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback
|
// OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback
|
||||||
// This ensures library screens show the change even before API completes
|
// This ensures library screens show the change even before API completes
|
||||||
_removeFromLocalLibrary(mediaType, libraryItemId);
|
_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
|
// 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
|
// Invalidate caches that could show stale inLibrary status
|
||||||
_cacheService.invalidateSearchCache();
|
_cacheService.invalidateSearchCache();
|
||||||
if (mediaType == 'album') {
|
if (mediaType == 'album') {
|
||||||
@@ -1790,6 +1828,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
} else if (mediaType == 'podcast') {
|
} else if (mediaType == 'podcast') {
|
||||||
_podcasts = await _api!.getPodcasts(limit: 100);
|
_podcasts = await _api!.getPodcasts(limit: 100);
|
||||||
}
|
}
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('⚠️ Background library refresh failed: $e');
|
_logger.log('⚠️ Background library refresh failed: $e');
|
||||||
@@ -3175,6 +3214,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('⚠️ Failed to refresh tracks: $e');
|
_logger.log('⚠️ Failed to refresh tracks: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_logger.log('✅ Force sync complete');
|
_logger.log('✅ Force sync complete');
|
||||||
}
|
}
|
||||||
@@ -3793,33 +3833,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (_availablePlayers.isNotEmpty) {
|
if (_availablePlayers.isNotEmpty) {
|
||||||
Player? playerToSelect;
|
Player? playerToSelect;
|
||||||
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
|
|
||||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
|
||||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||||
|
|
||||||
// Log settings for debugging player selection issues
|
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId, coldStart=$coldStart');
|
||||||
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
|
|
||||||
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
|
|
||||||
|
|
||||||
// If "Prefer Local Player" is ON, always try to select local player first
|
// SIMPLIFIED PLAYER SELECTION LOGIC:
|
||||||
// This takes priority even over the currently selected player
|
// 1. User's manual selection is always respected
|
||||||
if (preferLocalPlayer && builtinPlayerId != null) {
|
// 2. On app reopen: restore last selection
|
||||||
try {
|
// 3. If selected player unavailable: switch to local player
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
// 4. First launch (no history): select local player
|
||||||
(p) => p.playerId == builtinPlayerId && p.available,
|
// 5. Local player unavailable: select first 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
// Keep currently selected player if still available
|
||||||
// On coldStart, skip this block and apply full priority logic
|
if (_selectedPlayer != null && !coldStart) {
|
||||||
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
|
||||||
// Check if selected player is still available - also check translated Cast/Sendspin IDs
|
// 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 selectedId = _selectedPlayer!.playerId;
|
||||||
final translatedId = _castToSendspinIdMap[selectedId];
|
final translatedId = _castToSendspinIdMap[selectedId];
|
||||||
String? reverseTranslatedId;
|
String? reverseTranslatedId;
|
||||||
@@ -3836,103 +3863,55 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
|
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
|
||||||
);
|
);
|
||||||
if (stillAvailable) {
|
if (stillAvailable) {
|
||||||
// Find the actual player in the list (might be Cast or Sendspin version)
|
|
||||||
final currentPlayer = _availablePlayers.firstWhere(
|
final currentPlayer = _availablePlayers.firstWhere(
|
||||||
(p) => p.playerId == selectedId ||
|
(p) => p.playerId == selectedId ||
|
||||||
(translatedId != null && p.playerId == translatedId) ||
|
(translatedId != null && p.playerId == translatedId) ||
|
||||||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
|
(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;
|
playerToSelect = currentPlayer;
|
||||||
if (currentPlayer.playerId != selectedId) {
|
if (currentPlayer.playerId != selectedId) {
|
||||||
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
|
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
|
||||||
} else {
|
} else {
|
||||||
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
|
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Selected player went offline - will fall through to local player
|
||||||
|
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer available');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coldStart) {
|
// Priority 1: Restore last selected player (on coldStart)
|
||||||
_logger.log('🚀 Cold start: applying full priority logic');
|
if (playerToSelect == null && coldStart && lastSelectedPlayerId != null) {
|
||||||
|
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||||
|
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (playerToSelect != null) {
|
||||||
|
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerToSelect == null) {
|
// Priority 2: Local player (default fallback)
|
||||||
// Smart auto-selection priority (coldStart only for auto-switch to playing):
|
if (playerToSelect == null && builtinPlayerId != null) {
|
||||||
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
|
try {
|
||||||
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
|
|
||||||
// Priority 3: Last manually selected player
|
|
||||||
// Priority 4: First available player
|
|
||||||
|
|
||||||
// Priority 1: Auto-switch to single playing player
|
|
||||||
// ONLY on coldStart AND only if disableAutoSwitch is OFF
|
|
||||||
// Also apply cooldown to prevent rapid successive auto-switches
|
|
||||||
final cooldownActive = _lastPlayerSwitchTime != null &&
|
|
||||||
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
|
|
||||||
|
|
||||||
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
|
|
||||||
final playingPlayers = _availablePlayers.where(
|
|
||||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
|
||||||
).toList();
|
|
||||||
if (playingPlayers.length == 1) {
|
|
||||||
playerToSelect = playingPlayers.first;
|
|
||||||
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
|
|
||||||
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
|
|
||||||
} else if (playingPlayers.length > 1) {
|
|
||||||
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
|
|
||||||
}
|
|
||||||
} else if (cooldownActive) {
|
|
||||||
_logger.log('⏳ Auto-switch cooldown active, skipping');
|
|
||||||
} else if (!coldStart) {
|
|
||||||
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
|
|
||||||
} else if (disableAutoSwitch) {
|
|
||||||
_logger.log('⚠️ Auto-switch disabled by user setting');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
|
|
||||||
if (playerToSelect == null && builtinPlayerId != null) {
|
|
||||||
try {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == builtinPlayerId && p.available,
|
|
||||||
);
|
|
||||||
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
|
|
||||||
} catch (e) {
|
|
||||||
// Local player not found or not available - check why
|
|
||||||
final localPlayer = _availablePlayers
|
|
||||||
.where((p) => p.playerId == builtinPlayerId)
|
|
||||||
.toList();
|
|
||||||
if (localPlayer.isEmpty) {
|
|
||||||
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
|
|
||||||
} else {
|
|
||||||
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (playerToSelect == null && builtinPlayerId == null) {
|
|
||||||
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Last manually selected player
|
|
||||||
if (playerToSelect == null && lastSelectedPlayerId != null) {
|
|
||||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
|
||||||
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
if (playerToSelect != null) {
|
|
||||||
_logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 4: First available player
|
|
||||||
if (playerToSelect == null) {
|
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
(p) => p.available,
|
(p) => p.playerId == builtinPlayerId && p.available,
|
||||||
orElse: () => _availablePlayers.first,
|
|
||||||
);
|
);
|
||||||
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
_logger.log('📱 Selected local player: ${playerToSelect.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Local player not available');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 3: First available player
|
||||||
|
if (playerToSelect == null) {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.available,
|
||||||
|
orElse: () => _availablePlayers.first,
|
||||||
|
);
|
||||||
|
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
|
||||||
|
}
|
||||||
|
|
||||||
selectPlayer(playerToSelect);
|
selectPlayer(playerToSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4715,19 +4694,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No track data available - show player name placeholder for active players
|
// No queue data available from API
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
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;
|
_currentTrack = null;
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
// Persist cleared track state
|
// Persist cleared track state
|
||||||
_persistPlaybackState();
|
_persistPlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show player name placeholder so notification shows correct player
|
// Show player name placeholder only if we have no track data
|
||||||
if (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused') {
|
if (_currentTrack == null && (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused')) {
|
||||||
final mediaItem = audio_service.MediaItem(
|
final mediaItem = audio_service.MediaItem(
|
||||||
id: 'player_${_selectedPlayer!.playerId}',
|
id: 'player_${_selectedPlayer!.playerId}',
|
||||||
title: _selectedPlayer!.name,
|
title: _selectedPlayer!.name,
|
||||||
@@ -4749,7 +4741,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
duration: Duration.zero,
|
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();
|
audioHandler.clearRemotePlaybackState();
|
||||||
_startIdleServiceTimer();
|
_startIdleServiceTimer();
|
||||||
_positionTracker.clear();
|
_positionTracker.clear();
|
||||||
@@ -4843,6 +4850,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_albums = syncService.cachedAlbums;
|
_albums = syncService.cachedAlbums;
|
||||||
_artists = syncService.cachedArtists;
|
_artists = syncService.cachedArtists;
|
||||||
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
|
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4860,6 +4868,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Trigger background sync (non-blocking)
|
// Trigger background sync (non-blocking)
|
||||||
@@ -4886,6 +4895,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_albums = syncService.cachedAlbums;
|
_albums = syncService.cachedAlbums;
|
||||||
_artists = syncService.cachedArtists;
|
_artists = syncService.cachedArtists;
|
||||||
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
|
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
syncService.removeListener(onSyncComplete);
|
syncService.removeListener(onSyncComplete);
|
||||||
@@ -4983,6 +4993,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
SyncService.instance.updateCachedArtists(_artists);
|
SyncService.instance.updateCachedArtists(_artists);
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
||||||
@@ -5018,6 +5029,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
SyncService.instance.updateCachedAlbums(_albums);
|
SyncService.instance.updateCachedAlbums(_albums);
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
_syncLibraryStatusToService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import '../theme/theme_provider.dart';
|
|||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/recently_played_service.dart';
|
import '../services/recently_played_service.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/provider_icon.dart';
|
import '../widgets/provider_icon.dart';
|
||||||
import '../widgets/hires_badge.dart';
|
import '../widgets/hires_badge.dart';
|
||||||
import '../widgets/media_context_menu.dart';
|
import '../widgets/media_context_menu.dart';
|
||||||
|
import '../widgets/library_status_builder.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
@@ -36,12 +38,10 @@ class AlbumDetailsScreen extends StatefulWidget {
|
|||||||
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
|
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
|
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin {
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
List<Track> _tracks = [];
|
List<Track> _tracks = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isFavorite = false;
|
|
||||||
bool _isInLibrary = false;
|
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
@@ -53,11 +53,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'album',
|
||||||
|
widget.album.provider,
|
||||||
|
widget.album.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.album.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.album.inLibrary;
|
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();
|
_loadTracks();
|
||||||
_loadAlbumDescription();
|
_loadAlbumDescription();
|
||||||
|
|
||||||
@@ -97,8 +111,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
if (freshAlbum != null && mounted) {
|
if (freshAlbum != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_freshAlbum = freshAlbum;
|
_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
|
// Re-extract colors now that we have fresh album with images
|
||||||
_extractColors();
|
_extractColors();
|
||||||
}
|
}
|
||||||
@@ -134,9 +154,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isFavorite;
|
|
||||||
bool success;
|
bool success;
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -182,6 +206,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
if (libraryItemId == null) {
|
if (libraryItemId == null) {
|
||||||
_logger.log('Error: Could not determine library_item_id for removal');
|
_logger.log('Error: Could not determine library_item_id for removal');
|
||||||
|
rollbackFavoriteOperation();
|
||||||
throw Exception('Could not determine library ID for this album');
|
throw Exception('Could not determine library ID for this album');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Optimistically update UI regardless of online/offline state
|
completeFavoriteOperation();
|
||||||
setState(() {
|
|
||||||
_isFavorite = newState;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalidate home cache so the home screen shows updated favorite status
|
// Invalidate home cache so the home screen shows updated favorite status
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
@@ -207,14 +228,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
content: Text(
|
content: Text(
|
||||||
isOffline
|
isOffline
|
||||||
? S.of(context)!.actionQueuedForSync
|
? 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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
_logger.log('Error toggling favorite: $e');
|
_logger.log('Error toggling favorite: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -230,10 +254,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
/// Toggle library status
|
/// Toggle library status
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isInLibrary;
|
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
// Add to library - MUST use non-library provider
|
// Add to library - MUST use non-library provider
|
||||||
String? actualProvider;
|
String? actualProvider;
|
||||||
@@ -263,10 +287,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMISTIC UPDATE: Update UI immediately
|
// OPTIMISTIC UPDATE via centralized service
|
||||||
setState(() {
|
setLibraryStatus(newState);
|
||||||
_isInLibrary = newState;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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');
|
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
|
||||||
// Fire and forget - API call happens in background
|
final success = await maProvider.addToLibrary(
|
||||||
maProvider.addToLibrary(
|
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
_logger.log('❌ Failed to add album to library: $e');
|
|
||||||
// Revert on failure
|
if (success) {
|
||||||
if (mounted) {
|
completeLibraryOperation();
|
||||||
setState(() {
|
} else {
|
||||||
_isInLibrary = !newState;
|
rollbackLibraryOperation();
|
||||||
});
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Remove from library
|
// Remove from library
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
@@ -312,10 +331,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMISTIC UPDATE: Update UI immediately
|
// OPTIMISTIC UPDATE via centralized service
|
||||||
setState(() {
|
setLibraryStatus(newState);
|
||||||
_isInLibrary = newState;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -326,21 +343,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire and forget - API call happens in background
|
final success = await maProvider.removeFromLibrary(
|
||||||
maProvider.removeFromLibrary(
|
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
_logger.log('❌ Failed to remove album from library: $e');
|
|
||||||
// Revert on failure
|
if (success) {
|
||||||
if (mounted) {
|
completeLibraryOperation();
|
||||||
setState(() {
|
} else {
|
||||||
_isInLibrary = !newState;
|
rollbackLibraryOperation();
|
||||||
});
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
rollbackLibraryOperation();
|
||||||
_logger.log('Error toggling album library: $e');
|
_logger.log('Error toggling album library: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -1019,8 +1034,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
Symbols.book_2,
|
Symbols.book_2,
|
||||||
size: 25,
|
size: 25,
|
||||||
fill: _isInLibrary ? 1 : 0,
|
fill: isInLibrary ? 1 : 0,
|
||||||
color: _isInLibrary
|
color: isInLibrary
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: Colors.white70,
|
: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -1044,7 +1059,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.favorite,
|
Icons.favorite,
|
||||||
size: 25,
|
size: 25,
|
||||||
color: _isFavorite
|
color: isFavorite
|
||||||
? colorScheme.error
|
? colorScheme.error
|
||||||
: Colors.white70,
|
: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -1068,8 +1083,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
position: position,
|
position: position,
|
||||||
mediaType: ContextMenuMediaType.album,
|
mediaType: ContextMenuMediaType.album,
|
||||||
item: widget.album,
|
item: widget.album,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
adaptiveColorScheme: adaptiveScheme,
|
adaptiveColorScheme: adaptiveScheme,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import '../services/debug_logger.dart';
|
|||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
|
import '../widgets/library_status_builder.dart';
|
||||||
|
|
||||||
class ArtistDetailsScreen extends StatefulWidget {
|
class ArtistDetailsScreen extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
@@ -35,13 +37,11 @@ class ArtistDetailsScreen extends StatefulWidget {
|
|||||||
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryStatusMixin {
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
List<Album> _albums = [];
|
List<Album> _albums = [];
|
||||||
List<Album> _providerAlbums = [];
|
List<Album> _providerAlbums = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isFavorite = false;
|
|
||||||
bool _isInLibrary = false;
|
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
@@ -49,6 +49,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
String? _artistImageUrl;
|
String? _artistImageUrl;
|
||||||
MusicAssistantProvider? _maProvider;
|
MusicAssistantProvider? _maProvider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'artist',
|
||||||
|
widget.artist.provider,
|
||||||
|
widget.artist.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
// View preferences
|
// View preferences
|
||||||
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
||||||
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
||||||
@@ -58,8 +65,15 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.artist.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = _checkIfInLibrary(widget.artist);
|
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
|
// Use initial image URL immediately for smooth hero animation
|
||||||
_artistImageUrl = widget.initialImageUrl;
|
_artistImageUrl = widget.initialImageUrl;
|
||||||
_loadViewPreferences();
|
_loadViewPreferences();
|
||||||
@@ -96,12 +110,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
void _onProviderChanged() {
|
void _onProviderChanged() {
|
||||||
if (!mounted) return;
|
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();
|
final newIsInLibrary = _checkIfInLibraryFromProvider();
|
||||||
if (newIsInLibrary != _isInLibrary) {
|
final service = LibraryStatusService.instance;
|
||||||
setState(() {
|
final key = libraryItemKey;
|
||||||
_isInLibrary = newIsInLibrary;
|
if (newIsInLibrary != service.isInLibrary(key)) {
|
||||||
});
|
service.setLibraryStatus(key, newIsInLibrary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,9 +228,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
try {
|
try {
|
||||||
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
|
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
|
||||||
if (freshArtist != null && mounted) {
|
if (freshArtist != null && mounted) {
|
||||||
setState(() {
|
// Sync with centralized service
|
||||||
_isFavorite = freshArtist.favorite ?? false;
|
final service = LibraryStatusService.instance;
|
||||||
});
|
final key = libraryItemKey;
|
||||||
|
final newFavorite = freshArtist.favorite ?? false;
|
||||||
|
if (service.isFavorite(key) != newFavorite) {
|
||||||
|
service.setFavoriteStatus(key, newFavorite);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error refreshing artist favorite status: $e');
|
_logger.log('Error refreshing artist favorite status: $e');
|
||||||
@@ -225,9 +243,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isFavorite;
|
|
||||||
bool success;
|
bool success;
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -270,6 +292,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
if (libraryItemId == null) {
|
if (libraryItemId == null) {
|
||||||
_logger.log('Error: Could not determine library_item_id for removal');
|
_logger.log('Error: Could not determine library_item_id for removal');
|
||||||
|
rollbackFavoriteOperation();
|
||||||
throw Exception('Could not determine library ID for this artist');
|
throw Exception('Could not determine library ID for this artist');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,10 +303,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
setState(() {
|
completeFavoriteOperation();
|
||||||
_isFavorite = newState;
|
|
||||||
});
|
|
||||||
|
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -293,14 +313,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
isOffline
|
isOffline
|
||||||
? S.of(context)!.actionQueuedForSync
|
? 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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
_logger.log('Error toggling artist favorite: $e');
|
_logger.log('Error toggling artist favorite: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -322,10 +345,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
/// Toggle library status
|
/// Toggle library status
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isInLibrary;
|
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
// Add to library - MUST use non-library provider
|
// Add to library - MUST use non-library provider
|
||||||
String? actualProvider;
|
String? actualProvider;
|
||||||
@@ -355,10 +378,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMISTIC UPDATE: Update UI immediately
|
// Optimistic update via centralized service
|
||||||
setState(() {
|
setLibraryStatus(newState);
|
||||||
_isInLibrary = newState;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -370,21 +391,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
||||||
// Fire and forget - API call happens in background
|
final success = await maProvider.addToLibrary(
|
||||||
maProvider.addToLibrary(
|
|
||||||
mediaType: 'artist',
|
mediaType: 'artist',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
_logger.log('❌ Failed to add artist to library: $e');
|
|
||||||
// Revert on failure
|
if (success) {
|
||||||
if (mounted) {
|
completeLibraryOperation();
|
||||||
setState(() {
|
} else {
|
||||||
_isInLibrary = !newState;
|
rollbackLibraryOperation();
|
||||||
});
|
}
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Remove from library
|
// Remove from library
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
@@ -405,10 +422,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMISTIC UPDATE: Update UI immediately
|
// Optimistic update via centralized service
|
||||||
setState(() {
|
setLibraryStatus(newState);
|
||||||
_isInLibrary = newState;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -419,22 +434,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire and forget - API call happens in background
|
final success = await maProvider.removeFromLibrary(
|
||||||
maProvider.removeFromLibrary(
|
|
||||||
mediaType: 'artist',
|
mediaType: 'artist',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
_logger.log('❌ Failed to remove artist from library: $e');
|
|
||||||
// Revert on failure
|
if (success) {
|
||||||
if (mounted) {
|
completeLibraryOperation();
|
||||||
setState(() {
|
} else {
|
||||||
_isInLibrary = !newState;
|
rollbackLibraryOperation();
|
||||||
});
|
}
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
rollbackLibraryOperation();
|
||||||
_logger.log('Error toggling artist library: $e');
|
_logger.log('Error toggling artist library: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -1009,8 +1021,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
Symbols.book_2,
|
Symbols.book_2,
|
||||||
size: 25,
|
size: 25,
|
||||||
fill: _isInLibrary ? 1 : 0,
|
fill: isInLibrary ? 1 : 0,
|
||||||
color: _isInLibrary
|
color: isInLibrary
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: Colors.white70,
|
: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -1034,7 +1046,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.favorite,
|
Icons.favorite,
|
||||||
size: 25,
|
size: 25,
|
||||||
color: _isFavorite
|
color: isFavorite
|
||||||
? colorScheme.error
|
? colorScheme.error
|
||||||
: Colors.white70,
|
: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -1057,8 +1069,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
position: position,
|
position: position,
|
||||||
mediaType: ContextMenuMediaType.artist,
|
mediaType: ContextMenuMediaType.artist,
|
||||||
item: widget.artist,
|
item: widget.artist,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
adaptiveColorScheme: adaptiveScheme,
|
adaptiveColorScheme: adaptiveScheme,
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
// Home row order
|
// Home row order
|
||||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||||
// Player settings
|
// Player settings
|
||||||
bool _preferLocalPlayer = false;
|
|
||||||
bool _smartSortPlayers = false;
|
bool _smartSortPlayers = false;
|
||||||
bool _disableAutoSwitch = false;
|
|
||||||
bool _volumePrecisionMode = true;
|
bool _volumePrecisionMode = true;
|
||||||
// Hint settings
|
// Hint settings
|
||||||
bool _showHints = true;
|
bool _showHints = true;
|
||||||
@@ -86,9 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||||
|
|
||||||
// Load player settings
|
// Load player settings
|
||||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
|
||||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
|
||||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||||
|
|
||||||
// Load hint settings
|
// Load hint settings
|
||||||
@@ -112,9 +108,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
_showDiscoverSeries = showDiscSeries;
|
_showDiscoverSeries = showDiscSeries;
|
||||||
_homeRowOrder = rowOrder;
|
_homeRowOrder = rowOrder;
|
||||||
_preferLocalPlayer = preferLocal;
|
|
||||||
_smartSortPlayers = smartSort;
|
_smartSortPlayers = smartSort;
|
||||||
_disableAutoSwitch = disableAutoSwitch;
|
|
||||||
_volumePrecisionMode = volumePrecision;
|
_volumePrecisionMode = volumePrecision;
|
||||||
_showHints = showHints;
|
_showHints = showHints;
|
||||||
_showProviderIcons = showProviderIcons;
|
_showProviderIcons = showProviderIcons;
|
||||||
@@ -703,33 +697,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -754,32 +721,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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)!.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),
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -193,6 +193,33 @@ class DatabaseService {
|
|||||||
return db.markItemsDeleted(itemType, [itemId]);
|
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
|
/// Batch cache multiple items efficiently
|
||||||
/// Uses drift's batch API for much better performance on large datasets.
|
/// Uses drift's batch API for much better performance on large datasets.
|
||||||
/// Items are automatically chunked into batches of 500.
|
/// Items are automatically chunked into batches of 500.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class HardwareVolumeService {
|
|||||||
factory HardwareVolumeService() => _instance;
|
factory HardwareVolumeService() => _instance;
|
||||||
HardwareVolumeService._internal();
|
HardwareVolumeService._internal();
|
||||||
|
|
||||||
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
|
static const _channel = MethodChannel('com.collotsspot.ensemble/volume_buttons');
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
final _volumeUpController = StreamController<void>.broadcast();
|
final _volumeUpController = StreamController<void>.broadcast();
|
||||||
|
|||||||
@@ -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 _keyLocalPlayerName = 'local_player_name';
|
||||||
static const String _keyOwnerName = 'owner_name';
|
static const String _keyOwnerName = 'owner_name';
|
||||||
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
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 _keySmartSortPlayers = 'smart_sort_players';
|
||||||
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
|
|
||||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||||
@@ -355,18 +353,10 @@ class SettingsService {
|
|||||||
static Future<void> setLastSelectedPlayerId(String? playerId) =>
|
static Future<void> setLastSelectedPlayerId(String? playerId) =>
|
||||||
_setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true);
|
_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
|
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
|
||||||
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
|
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
|
||||||
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
|
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
|
// Helper to create player name with possessive apostrophe
|
||||||
// Automatically detects Phone vs Tablet based on screen size
|
// Automatically detects Phone vs Tablet based on screen size
|
||||||
static String _makePlayerName(String ownerName) {
|
static String _makePlayerName(String ownerName) {
|
||||||
|
|||||||
@@ -596,4 +596,78 @@ class SyncService with ChangeNotifier {
|
|||||||
_cachedPlaylists = playlists;
|
_cachedPlaylists = playlists;
|
||||||
notifyListeners();
|
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 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_color_utilities/material_color_utilities.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
@@ -8,6 +10,16 @@ import '../services/debug_logger.dart';
|
|||||||
|
|
||||||
final _logger = DebugLogger();
|
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
|
/// Extracted adaptive colors from album art
|
||||||
class AdaptiveColors {
|
class AdaptiveColors {
|
||||||
final Color primary; // Main accent color (vibrant)
|
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) {
|
Color getContrastingTextColor(Color backgroundColor) {
|
||||||
// Use relative luminance to determine if we need light or dark text
|
// Calculate contrast with both black and white, pick the one with better contrast
|
||||||
// Standard threshold is 0.179 based on WCAG guidelines
|
final contrastWithWhite = _contrastRatio(backgroundColor, Colors.white);
|
||||||
return backgroundColor.computeLuminance() > 0.4 ? Colors.black : 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
|
/// Top-level function for isolate-based color extraction
|
||||||
@@ -156,11 +253,22 @@ class PaletteHelper {
|
|||||||
static final Map<String, Uint8List> _imageCache = {};
|
static final Map<String, Uint8List> _imageCache = {};
|
||||||
static const int _maxCacheSize = 20;
|
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
|
/// Extract color schemes from a URL using isolate-based processing
|
||||||
/// This is the preferred method as it doesn't block the main thread
|
/// 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 {
|
static Future<(ColorScheme, ColorScheme)?> extractColorSchemesFromUrl(String imageUrl) async {
|
||||||
try {
|
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];
|
Uint8List? imageBytes = _imageCache[imageUrl];
|
||||||
|
|
||||||
if (imageBytes == null) {
|
if (imageBytes == null) {
|
||||||
@@ -184,14 +292,22 @@ class PaletteHelper {
|
|||||||
if (extractedColors == null) return null;
|
if (extractedColors == null) return null;
|
||||||
|
|
||||||
// Convert to ColorSchemes on main thread
|
// 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) {
|
} catch (e) {
|
||||||
_logger.warning('Failed to extract colors from URL: $e', context: 'Palette');
|
_logger.warning('Failed to extract colors from URL: $e', context: 'Palette');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build ColorSchemes from extracted color data
|
/// Build ColorSchemes from extracted color data using HCT color space
|
||||||
static (ColorScheme, ColorScheme) _buildColorSchemes(_ExtractedColors colors) {
|
static (ColorScheme, ColorScheme) _buildColorSchemes(_ExtractedColors colors) {
|
||||||
// Convert int colors to Color objects
|
// Convert int colors to Color objects
|
||||||
final vibrant = Color(colors.vibrantColor);
|
final vibrant = Color(colors.vibrantColor);
|
||||||
@@ -201,69 +317,85 @@ class PaletteHelper {
|
|||||||
final muted = Color(colors.mutedColor);
|
final muted = Color(colors.mutedColor);
|
||||||
final lightMuted = Color(colors.lightMutedColor);
|
final lightMuted = Color(colors.lightMutedColor);
|
||||||
|
|
||||||
// Get primary color - prefer vibrant, then dominant
|
// Get primary color - prefer vibrant, then lightVibrant, then dominant
|
||||||
Color primary = vibrant;
|
Color primary = vibrant;
|
||||||
if (vibrant.computeLuminance() < 0.1) {
|
final vibrantHct = Hct.fromInt(vibrant.value);
|
||||||
|
if (vibrantHct.tone < 20 || vibrantHct.chroma < 8) {
|
||||||
primary = lightVibrant;
|
primary = lightVibrant;
|
||||||
}
|
}
|
||||||
if (primary.computeLuminance() < 0.1) {
|
final primaryHct = Hct.fromInt(primary.value);
|
||||||
|
if (primaryHct.tone < 20 || primaryHct.chroma < 8) {
|
||||||
primary = dominant;
|
primary = dominant;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure primary has enough saturation
|
// Use HCT for perceptually accurate color adjustment
|
||||||
final hslPrimary = HSLColor.fromColor(primary);
|
// Ensure minimum chroma (colorfulness) so colors aren't muddy
|
||||||
Color adjustedPrimary = primary;
|
Color adjustedPrimary = _ensureMinChroma(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build dark mode colors
|
// Build dark mode colors using HCT
|
||||||
final hslDarkSurface = HSLColor.fromColor(darkMuted);
|
final darkMutedHct = Hct.fromInt(darkMuted.value);
|
||||||
final darkSurface = hslDarkSurface
|
final darkSurface = Color(Hct.from(
|
||||||
.withLightness((hslDarkSurface.lightness * 0.3).clamp(0.05, 0.15))
|
darkMutedHct.hue,
|
||||||
.toColor();
|
math.min(darkMutedHct.chroma, _minSurfaceChroma), // Low chroma for surface
|
||||||
final darkMiniPlayer = hslDarkSurface
|
math.max(darkMutedHct.tone * 0.3, 5.0).clamp(5.0, 15.0), // Very dark
|
||||||
.withLightness(0.3.clamp(0.25, 0.38))
|
).toInt());
|
||||||
.withSaturation((hslDarkSurface.saturation * 1.2).clamp(0.15, 0.5))
|
final darkMiniPlayer = Color(Hct.from(
|
||||||
.toColor();
|
darkMutedHct.hue,
|
||||||
|
(darkMutedHct.chroma * 1.2).clamp(4, 16),
|
||||||
|
30, // Fixed tone for consistency
|
||||||
|
).toInt());
|
||||||
|
|
||||||
// Build light mode colors
|
// Build light mode colors using HCT
|
||||||
final hslLightSurface = HSLColor.fromColor(lightMuted);
|
final lightMutedHct = Hct.fromInt(lightMuted.value);
|
||||||
final lightSurface = hslLightSurface
|
final lightSurface = Color(Hct.from(
|
||||||
.withLightness(hslLightSurface.lightness.clamp(0.92, 0.98))
|
lightMutedHct.hue,
|
||||||
.toColor();
|
math.min(lightMutedHct.chroma, _minSurfaceChroma),
|
||||||
final lightMiniPlayer = hslLightSurface
|
95, // Very light
|
||||||
.withLightness(0.75.clamp(0.65, 0.8))
|
).toInt());
|
||||||
.withSaturation((hslLightSurface.saturation * 1.2).clamp(0.15, 0.5))
|
final lightMiniPlayer = Color(Hct.from(
|
||||||
.toColor();
|
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
|
// Determine contrasting text colors
|
||||||
final lightOnPrimary = getContrastingTextColor(adjustedPrimary);
|
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
|
||||||
final darkOnPrimary = getContrastingTextColor(adjustedPrimary);
|
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
|
||||||
final lightOnMiniPlayer = getContrastingTextColor(lightMiniPlayer);
|
final lightOnMiniPlayer = getContrastingTextColor(lightMiniPlayer);
|
||||||
final darkOnMiniPlayer = getContrastingTextColor(darkMiniPlayer);
|
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(
|
final lightScheme = ColorScheme(
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
primary: adjustedPrimary,
|
primary: lightAdjustedPrimary,
|
||||||
onPrimary: lightOnPrimary,
|
onPrimary: lightOnPrimary,
|
||||||
secondary: adjustedPrimary.withOpacity(0.8),
|
secondary: lightAdjustedPrimary.withOpacity(0.8),
|
||||||
onSecondary: lightOnPrimary,
|
onSecondary: lightOnPrimary,
|
||||||
secondaryContainer: lightSecondaryContainer,
|
secondaryContainer: lightSecondaryContainer,
|
||||||
onSecondaryContainer: Colors.black87,
|
onSecondaryContainer: Colors.black87,
|
||||||
@@ -277,9 +409,9 @@ class PaletteHelper {
|
|||||||
|
|
||||||
final darkScheme = ColorScheme(
|
final darkScheme = ColorScheme(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: adjustedPrimary,
|
primary: darkAdjustedPrimary,
|
||||||
onPrimary: darkOnPrimary,
|
onPrimary: darkOnPrimary,
|
||||||
secondary: adjustedPrimary.withOpacity(0.8),
|
secondary: darkAdjustedPrimary.withOpacity(0.8),
|
||||||
onSecondary: darkOnPrimary,
|
onSecondary: darkOnPrimary,
|
||||||
secondaryContainer: darkSecondaryContainer,
|
secondaryContainer: darkSecondaryContainer,
|
||||||
onSecondaryContainer: Colors.white,
|
onSecondaryContainer: Colors.white,
|
||||||
@@ -294,9 +426,10 @@ class PaletteHelper {
|
|||||||
return (lightScheme, darkScheme);
|
return (lightScheme, darkScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the image cache
|
/// Clear all caches (image bytes and color schemes)
|
||||||
static void clearCache() {
|
static void clearCache() {
|
||||||
_imageCache.clear();
|
_imageCache.clear();
|
||||||
|
_colorSchemeCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a color palette from an image with higher color count for better variety
|
/// 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}) {
|
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
|
||||||
if (palette == null) return null;
|
if (palette == null) return null;
|
||||||
|
|
||||||
@@ -324,22 +457,9 @@ class PaletteHelper {
|
|||||||
palette.dominantColor?.color ??
|
palette.dominantColor?.color ??
|
||||||
const Color(0xFF604CEC);
|
const Color(0xFF604CEC);
|
||||||
|
|
||||||
// Ensure primary has enough saturation to be visually distinct
|
// Use HCT for perceptually accurate color adjustment
|
||||||
// AND enough lightness for white text to be readable on it
|
// Ensure minimum chroma (colorfulness) so colors aren't muddy
|
||||||
final HSLColor hslPrimary = HSLColor.fromColor(primary);
|
Color adjustedPrimary = _ensureMinChroma(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
// Dark mode: use dark muted colors for background
|
// Dark mode: use dark muted colors for background
|
||||||
@@ -347,18 +467,19 @@ class PaletteHelper {
|
|||||||
palette.mutedColor?.color ??
|
palette.mutedColor?.color ??
|
||||||
const Color(0xFF121212);
|
const Color(0xFF121212);
|
||||||
|
|
||||||
// Darken the surface color significantly for the expanded background
|
// Build surface colors using HCT
|
||||||
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
final surfaceHct = Hct.fromInt(surfaceBase.value);
|
||||||
final Color surface = hslSurface
|
final Color surface = Color(Hct.from(
|
||||||
.withLightness((hslSurface.lightness * 0.3).clamp(0.05, 0.15))
|
surfaceHct.hue,
|
||||||
.toColor();
|
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
|
final Color miniPlayer = Color(Hct.from(
|
||||||
// Use the muted color but at medium lightness (0.25-0.35 range)
|
surfaceHct.hue,
|
||||||
final Color miniPlayer = hslSurface
|
(surfaceHct.chroma * 1.2).clamp(4, 16),
|
||||||
.withLightness(0.3.clamp(0.25, 0.38))
|
30,
|
||||||
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
).toInt());
|
||||||
.toColor();
|
|
||||||
|
|
||||||
return AdaptiveColors(
|
return AdaptiveColors(
|
||||||
primary: adjustedPrimary,
|
primary: adjustedPrimary,
|
||||||
@@ -372,16 +493,18 @@ class PaletteHelper {
|
|||||||
palette.mutedColor?.color ??
|
palette.mutedColor?.color ??
|
||||||
Colors.white;
|
Colors.white;
|
||||||
|
|
||||||
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
final surfaceHct = Hct.fromInt(surfaceBase.value);
|
||||||
final Color surface = hslSurface
|
final Color surface = Color(Hct.from(
|
||||||
.withLightness((hslSurface.lightness).clamp(0.92, 0.98))
|
surfaceHct.hue,
|
||||||
.toColor();
|
math.min(surfaceHct.chroma, _minSurfaceChroma),
|
||||||
|
95,
|
||||||
|
).toInt());
|
||||||
|
|
||||||
// Mini player in light mode - medium tinted
|
final Color miniPlayer = Color(Hct.from(
|
||||||
final Color miniPlayer = hslSurface
|
surfaceHct.hue,
|
||||||
.withLightness(0.75.clamp(0.65, 0.8))
|
(surfaceHct.chroma * 1.2).clamp(4, 16),
|
||||||
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
75,
|
||||||
.toColor();
|
).toInt());
|
||||||
|
|
||||||
return AdaptiveColors(
|
return AdaptiveColors(
|
||||||
primary: adjustedPrimary,
|
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) {
|
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
|
||||||
if (palette == null) return null;
|
if (palette == null) return null;
|
||||||
|
|
||||||
@@ -401,29 +524,44 @@ class PaletteHelper {
|
|||||||
|
|
||||||
if (lightColors == null || darkColors == null) return null;
|
if (lightColors == null || darkColors == null) return null;
|
||||||
|
|
||||||
// Determine contrasting text colors based on primary color luminance
|
// Generate secondary container colors using HCT
|
||||||
final lightOnPrimary = getContrastingTextColor(lightColors.primary);
|
final lightPrimaryHct = Hct.fromInt(lightColors.primary.value);
|
||||||
final darkOnPrimary = getContrastingTextColor(darkColors.primary);
|
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 lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
|
||||||
final darkOnMiniPlayer = getContrastingTextColor(darkColors.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(
|
final lightScheme = ColorScheme(
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
primary: lightColors.primary,
|
primary: lightAdjustedPrimary,
|
||||||
onPrimary: lightOnPrimary,
|
onPrimary: lightOnPrimary,
|
||||||
secondary: lightColors.primary.withOpacity(0.8),
|
secondary: lightAdjustedPrimary.withOpacity(0.8),
|
||||||
onSecondary: lightOnPrimary,
|
onSecondary: lightOnPrimary,
|
||||||
secondaryContainer: lightSecondaryContainer,
|
secondaryContainer: lightSecondaryContainer,
|
||||||
onSecondaryContainer: Colors.black87,
|
onSecondaryContainer: Colors.black87,
|
||||||
@@ -437,9 +575,9 @@ class PaletteHelper {
|
|||||||
|
|
||||||
final darkScheme = ColorScheme(
|
final darkScheme = ColorScheme(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: darkColors.primary,
|
primary: darkAdjustedPrimary,
|
||||||
onPrimary: darkOnPrimary,
|
onPrimary: darkOnPrimary,
|
||||||
secondary: darkColors.primary.withOpacity(0.8),
|
secondary: darkAdjustedPrimary.withOpacity(0.8),
|
||||||
onSecondary: darkOnPrimary,
|
onSecondary: darkOnPrimary,
|
||||||
secondaryContainer: darkSecondaryContainer,
|
secondaryContainer: darkSecondaryContainer,
|
||||||
onSecondaryContainer: Colors.white,
|
onSecondaryContainer: Colors.white,
|
||||||
|
|||||||
+83
-38
@@ -11,9 +11,11 @@ import '../constants/timings.dart';
|
|||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'provider_icon.dart';
|
import 'provider_icon.dart';
|
||||||
import 'media_context_menu.dart';
|
import 'media_context_menu.dart';
|
||||||
|
import 'library_status_builder.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatefulWidget {
|
class AlbumCard extends StatefulWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
@@ -35,25 +37,37 @@ class AlbumCard extends StatefulWidget {
|
|||||||
State<AlbumCard> createState() => _AlbumCardState();
|
State<AlbumCard> createState() => _AlbumCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumCardState extends State<AlbumCard> {
|
class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
||||||
String? _fallbackImageUrl;
|
String? _fallbackImageUrl;
|
||||||
bool _triedFallback = false;
|
bool _triedFallback = false;
|
||||||
bool _maImageFailed = false;
|
bool _maImageFailed = false;
|
||||||
String? _cachedMaImageUrl;
|
String? _cachedMaImageUrl;
|
||||||
Timer? _fallbackTimer;
|
Timer? _fallbackTimer;
|
||||||
bool _isNavigating = false;
|
bool _isNavigating = false;
|
||||||
late bool _isFavorite;
|
|
||||||
late bool _isInLibrary;
|
|
||||||
|
|
||||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'album',
|
||||||
|
widget.album.provider,
|
||||||
|
widget.album.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.album.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.album.inLibrary;
|
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();
|
_initFallbackImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +125,11 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isFavorite;
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool success;
|
bool success;
|
||||||
@@ -150,7 +168,10 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
@@ -159,23 +180,26 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
setState(() => _isFavorite = newState);
|
completeFavoriteOperation();
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isInLibrary;
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -202,21 +226,29 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.addedToLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.addToLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.addToLibrary(
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
if (widget.album.provider == 'library') {
|
if (widget.album.provider == 'library') {
|
||||||
@@ -233,23 +265,31 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) return;
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.removedFromLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.removeFromLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.removedFromLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.removeFromLibrary(
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackLibraryOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +316,13 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
if (_isNavigating) return;
|
if (_isNavigating) return;
|
||||||
_isNavigating = true;
|
_isNavigating = true;
|
||||||
|
|
||||||
// PERF: Color extraction deferred to detail screen's initState
|
// Start color extraction immediately (fire-and-forget)
|
||||||
// to avoid competing with Hero animation for GPU resources
|
// 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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
@@ -300,8 +345,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
position: details.globalPosition,
|
position: details.globalPosition,
|
||||||
mediaType: ContextMenuMediaType.album,
|
mediaType: ContextMenuMediaType.album,
|
||||||
item: widget.album,
|
item: widget.album,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import '../constants/timings.dart';
|
|||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'media_context_menu.dart';
|
import 'media_context_menu.dart';
|
||||||
|
import 'library_status_builder.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatefulWidget {
|
class ArtistCard extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
@@ -34,7 +36,7 @@ class ArtistCard extends StatefulWidget {
|
|||||||
State<ArtistCard> createState() => _ArtistCardState();
|
State<ArtistCard> createState() => _ArtistCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistCardState extends State<ArtistCard> {
|
class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
String? _fallbackImageUrl;
|
String? _fallbackImageUrl;
|
||||||
bool _triedFallback = false;
|
bool _triedFallback = false;
|
||||||
@@ -42,18 +44,30 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
String? _cachedMaImageUrl;
|
String? _cachedMaImageUrl;
|
||||||
Timer? _fallbackTimer;
|
Timer? _fallbackTimer;
|
||||||
bool _isNavigating = false;
|
bool _isNavigating = false;
|
||||||
late bool _isFavorite;
|
|
||||||
late bool _isInLibrary;
|
|
||||||
|
|
||||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||||
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
|
||||||
static const _fallbackDelay = Duration(milliseconds: 400);
|
static const _fallbackDelay = Duration(milliseconds: 400);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'artist',
|
||||||
|
widget.artist.provider,
|
||||||
|
widget.artist.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.artist.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.artist.inLibrary;
|
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
|
// Fetch fallback image once in initState, not during build
|
||||||
_initFallbackImage();
|
_initFallbackImage();
|
||||||
}
|
}
|
||||||
@@ -102,7 +116,11 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isFavorite;
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool success;
|
bool success;
|
||||||
@@ -141,7 +159,10 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'artist',
|
mediaType: 'artist',
|
||||||
@@ -150,23 +171,26 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
setState(() => _isFavorite = newState);
|
completeFavoriteOperation();
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isInLibrary;
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -193,22 +217,29 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.addedToLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.addToLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.addToLibrary(
|
||||||
mediaType: 'artist',
|
mediaType: 'artist',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
return false;
|
if (success) {
|
||||||
});
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
if (widget.artist.provider == 'library') {
|
if (widget.artist.provider == 'library') {
|
||||||
@@ -225,24 +256,31 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) return;
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.removedFromLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.removeFromLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.removedFromLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.removeFromLibrary(
|
||||||
mediaType: 'artist',
|
mediaType: 'artist',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
return false;
|
if (success) {
|
||||||
});
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackLibraryOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +331,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
position: details.globalPosition,
|
position: details.globalPosition,
|
||||||
mediaType: ContextMenuMediaType.artist,
|
mediaType: ContextMenuMediaType.artist,
|
||||||
item: widget.artist,
|
item: widget.artist,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import '../services/debug_logger.dart';
|
|||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../screens/audiobook_detail_screen.dart';
|
import '../screens/audiobook_detail_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import 'provider_icon.dart';
|
import 'provider_icon.dart';
|
||||||
import 'media_context_menu.dart';
|
import 'media_context_menu.dart';
|
||||||
|
import 'library_status_builder.dart';
|
||||||
|
|
||||||
class AudiobookRow extends StatefulWidget {
|
class AudiobookRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -206,20 +208,35 @@ class _AudiobookCard extends StatefulWidget {
|
|||||||
State<_AudiobookCard> createState() => _AudiobookCardState();
|
State<_AudiobookCard> createState() => _AudiobookCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AudiobookCardState extends State<_AudiobookCard> {
|
class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin {
|
||||||
late bool _isFavorite;
|
@override
|
||||||
late bool _isInLibrary;
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'audiobook',
|
||||||
|
widget.audiobook.provider,
|
||||||
|
widget.audiobook.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.audiobook.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.audiobook.inLibrary;
|
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 {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isFavorite;
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool success;
|
bool success;
|
||||||
@@ -258,7 +275,10 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'audiobook',
|
mediaType: 'audiobook',
|
||||||
@@ -267,23 +287,26 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
setState(() => _isFavorite = newState);
|
completeFavoriteOperation();
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isInLibrary;
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -310,21 +333,29 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.addedToLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.addToLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.addToLibrary(
|
||||||
mediaType: 'audiobook',
|
mediaType: 'audiobook',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
if (widget.audiobook.provider == 'library') {
|
if (widget.audiobook.provider == 'library') {
|
||||||
@@ -341,23 +372,31 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
|||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) return;
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.removedFromLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.removeFromLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.removedFromLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.removeFromLibrary(
|
||||||
mediaType: 'audiobook',
|
mediaType: 'audiobook',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackLibraryOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,8 +425,8 @@ class _AudiobookCardState extends State<_AudiobookCard> {
|
|||||||
position: details.globalPosition,
|
position: details.globalPosition,
|
||||||
mediaType: ContextMenuMediaType.audiobook,
|
mediaType: ContextMenuMediaType.audiobook,
|
||||||
item: widget.audiobook,
|
item: widget.audiobook,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
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/hero_tags.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'provider_icon.dart';
|
import 'provider_icon.dart';
|
||||||
import 'media_context_menu.dart';
|
import 'media_context_menu.dart';
|
||||||
|
import 'library_status_builder.dart';
|
||||||
|
|
||||||
class PlaylistCard extends StatefulWidget {
|
class PlaylistCard extends StatefulWidget {
|
||||||
final Playlist playlist;
|
final Playlist playlist;
|
||||||
@@ -31,21 +33,37 @@ class PlaylistCard extends StatefulWidget {
|
|||||||
State<PlaylistCard> createState() => _PlaylistCardState();
|
State<PlaylistCard> createState() => _PlaylistCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlaylistCardState extends State<PlaylistCard> {
|
class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
|
||||||
bool _isNavigating = false;
|
bool _isNavigating = false;
|
||||||
late bool _isFavorite;
|
|
||||||
late bool _isInLibrary;
|
@override
|
||||||
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'playlist',
|
||||||
|
widget.playlist.provider,
|
||||||
|
widget.playlist.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.playlist.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.playlist.inLibrary;
|
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 {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isFavorite;
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool success;
|
bool success;
|
||||||
@@ -84,7 +102,10 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'playlist',
|
mediaType: 'playlist',
|
||||||
@@ -93,23 +114,26 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
setState(() => _isFavorite = newState);
|
completeFavoriteOperation();
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isInLibrary;
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -136,22 +160,29 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.addedToLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.addToLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.addToLibrary(
|
||||||
mediaType: 'playlist',
|
mediaType: 'playlist',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
return false;
|
if (success) {
|
||||||
});
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
if (widget.playlist.provider == 'library') {
|
if (widget.playlist.provider == 'library') {
|
||||||
@@ -168,24 +199,31 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) return;
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.removedFromLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.removeFromLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.removedFromLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.removeFromLibrary(
|
||||||
mediaType: 'playlist',
|
mediaType: 'playlist',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
return false;
|
if (success) {
|
||||||
});
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackLibraryOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +268,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
position: details.globalPosition,
|
position: details.globalPosition,
|
||||||
mediaType: ContextMenuMediaType.playlist,
|
mediaType: ContextMenuMediaType.playlist,
|
||||||
item: widget.playlist,
|
item: widget.playlist,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import '../constants/hero_tags.dart';
|
|||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import 'provider_icon.dart';
|
import 'provider_icon.dart';
|
||||||
import 'media_context_menu.dart';
|
import 'media_context_menu.dart';
|
||||||
|
import 'library_status_builder.dart';
|
||||||
|
|
||||||
class PodcastCard extends StatefulWidget {
|
class PodcastCard extends StatefulWidget {
|
||||||
final MediaItem podcast;
|
final MediaItem podcast;
|
||||||
@@ -34,21 +36,37 @@ class PodcastCard extends StatefulWidget {
|
|||||||
State<PodcastCard> createState() => _PodcastCardState();
|
State<PodcastCard> createState() => _PodcastCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PodcastCardState extends State<PodcastCard> {
|
class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
|
||||||
bool _isNavigating = false;
|
bool _isNavigating = false;
|
||||||
late bool _isFavorite;
|
|
||||||
late bool _isInLibrary;
|
@override
|
||||||
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'podcast',
|
||||||
|
widget.podcast.provider,
|
||||||
|
widget.podcast.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.podcast.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.podcast.inLibrary;
|
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 {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isFavorite;
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool success;
|
bool success;
|
||||||
@@ -87,7 +105,10 @@ class _PodcastCardState extends State<PodcastCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'podcast',
|
mediaType: 'podcast',
|
||||||
@@ -96,23 +117,26 @@ class _PodcastCardState extends State<PodcastCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
setState(() => _isFavorite = newState);
|
completeFavoriteOperation();
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isInLibrary;
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -139,21 +163,29 @@ class _PodcastCardState extends State<PodcastCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.addedToLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.addToLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.addToLibrary(
|
||||||
mediaType: 'podcast',
|
mediaType: 'podcast',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
if (widget.podcast.provider == 'library') {
|
if (widget.podcast.provider == 'library') {
|
||||||
@@ -170,23 +202,31 @@ class _PodcastCardState extends State<PodcastCard> {
|
|||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) return;
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.removedFromLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.removeFromLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.removedFromLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.removeFromLibrary(
|
||||||
mediaType: 'podcast',
|
mediaType: 'podcast',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackLibraryOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +274,8 @@ class _PodcastCardState extends State<PodcastCard> {
|
|||||||
position: details.globalPosition,
|
position: details.globalPosition,
|
||||||
mediaType: ContextMenuMediaType.podcast,
|
mediaType: ContextMenuMediaType.podcast,
|
||||||
item: widget.podcast,
|
item: widget.podcast,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import '../models/media_item.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'media_context_menu.dart';
|
import 'media_context_menu.dart';
|
||||||
|
import 'library_status_builder.dart';
|
||||||
|
|
||||||
class RadioStationCard extends StatefulWidget {
|
class RadioStationCard extends StatefulWidget {
|
||||||
final MediaItem radioStation;
|
final MediaItem radioStation;
|
||||||
@@ -28,21 +30,37 @@ class RadioStationCard extends StatefulWidget {
|
|||||||
State<RadioStationCard> createState() => _RadioStationCardState();
|
State<RadioStationCard> createState() => _RadioStationCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RadioStationCardState extends State<RadioStationCard> {
|
class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusMixin {
|
||||||
bool _isTapping = false;
|
bool _isTapping = false;
|
||||||
late bool _isFavorite;
|
|
||||||
late bool _isInLibrary;
|
@override
|
||||||
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'radio',
|
||||||
|
widget.radioStation.provider,
|
||||||
|
widget.radioStation.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.radioStation.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.radioStation.inLibrary;
|
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 {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isFavorite;
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool success;
|
bool success;
|
||||||
@@ -81,7 +99,10 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'radio',
|
mediaType: 'radio',
|
||||||
@@ -90,23 +111,26 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
setState(() => _isFavorite = newState);
|
completeFavoriteOperation();
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isInLibrary;
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -133,21 +157,29 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.addedToLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.addToLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.addToLibrary(
|
||||||
mediaType: 'radio',
|
mediaType: 'radio',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
if (widget.radioStation.provider == 'library') {
|
if (widget.radioStation.provider == 'library') {
|
||||||
@@ -164,23 +196,31 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
|||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) return;
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.removedFromLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.removeFromLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.removedFromLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.removeFromLibrary(
|
||||||
mediaType: 'radio',
|
mediaType: 'radio',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackLibraryOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,8 +259,8 @@ class _RadioStationCardState extends State<RadioStationCard> {
|
|||||||
position: details.globalPosition,
|
position: details.globalPosition,
|
||||||
mediaType: ContextMenuMediaType.radio,
|
mediaType: ContextMenuMediaType.radio,
|
||||||
item: widget.radioStation,
|
item: widget.radioStation,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
);
|
);
|
||||||
|
|||||||
+75
-36
@@ -6,7 +6,9 @@ import '../l10n/app_localizations.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/library_status_service.dart';
|
||||||
import 'media_context_menu.dart';
|
import 'media_context_menu.dart';
|
||||||
|
import 'library_status_builder.dart';
|
||||||
|
|
||||||
class TrackRow extends StatefulWidget {
|
class TrackRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -194,20 +196,35 @@ class _TrackCard extends StatefulWidget {
|
|||||||
State<_TrackCard> createState() => _TrackCardState();
|
State<_TrackCard> createState() => _TrackCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrackCardState extends State<_TrackCard> {
|
class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
|
||||||
late bool _isFavorite;
|
@override
|
||||||
late bool _isInLibrary;
|
String get libraryItemKey => LibraryStatusService.makeKey(
|
||||||
|
'track',
|
||||||
|
widget.track.provider,
|
||||||
|
widget.track.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_isFavorite = widget.track.favorite ?? false;
|
// Initialize status in centralized service from widget data
|
||||||
_isInLibrary = widget.track.inLibrary;
|
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 {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isFavorite;
|
final currentFavorite = isFavorite;
|
||||||
|
final newState = !currentFavorite;
|
||||||
|
|
||||||
|
// Optimistic update via centralized service
|
||||||
|
setFavoriteStatus(newState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool success;
|
bool success;
|
||||||
@@ -246,7 +263,10 @@ class _TrackCardState extends State<_TrackCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
success = await maProvider.removeFromFavorites(
|
success = await maProvider.removeFromFavorites(
|
||||||
mediaType: 'track',
|
mediaType: 'track',
|
||||||
@@ -255,23 +275,26 @@ class _TrackCardState extends State<_TrackCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
setState(() => _isFavorite = newState);
|
completeFavoriteOperation();
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackFavoriteOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLibrary() async {
|
Future<void> _toggleLibrary() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final newState = !_isInLibrary;
|
final currentInLibrary = isInLibrary;
|
||||||
|
final newState = !currentInLibrary;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -298,21 +321,29 @@ class _TrackCardState extends State<_TrackCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.addedToLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.addToLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.addedToLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.addToLibrary(
|
||||||
mediaType: 'track',
|
mediaType: 'track',
|
||||||
provider: actualProvider,
|
provider: actualProvider,
|
||||||
itemId: actualItemId,
|
itemId: actualItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
if (widget.track.provider == 'library') {
|
if (widget.track.provider == 'library') {
|
||||||
@@ -329,23 +360,31 @@ class _TrackCardState extends State<_TrackCard> {
|
|||||||
|
|
||||||
if (libraryItemId == null) return;
|
if (libraryItemId == null) return;
|
||||||
|
|
||||||
setState(() => _isInLibrary = newState);
|
// Optimistic update via centralized service
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setLibraryStatus(newState);
|
||||||
SnackBar(
|
|
||||||
content: Text(S.of(context)!.removedFromLibrary),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maProvider.removeFromLibrary(
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.removedFromLibrary),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await maProvider.removeFromLibrary(
|
||||||
mediaType: 'track',
|
mediaType: 'track',
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId: libraryItemId,
|
||||||
).catchError((e) {
|
);
|
||||||
if (mounted) setState(() => _isInLibrary = !newState);
|
|
||||||
});
|
if (success) {
|
||||||
|
completeLibraryOperation();
|
||||||
|
} else {
|
||||||
|
rollbackLibraryOperation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent failure
|
rollbackLibraryOperation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,8 +409,8 @@ class _TrackCardState extends State<_TrackCard> {
|
|||||||
position: details.globalPosition,
|
position: details.globalPosition,
|
||||||
mediaType: ContextMenuMediaType.track,
|
mediaType: ContextMenuMediaType.track,
|
||||||
item: widget.track,
|
item: widget.track,
|
||||||
isFavorite: _isFavorite,
|
isFavorite: isFavorite,
|
||||||
isInLibrary: _isInLibrary,
|
isInLibrary: isInLibrary,
|
||||||
onToggleFavorite: _toggleFavorite,
|
onToggleFavorite: _toggleFavorite,
|
||||||
onToggleLibrary: _toggleLibrary,
|
onToggleLibrary: _toggleLibrary,
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.9.2-beta+50
|
version: 3.0.0+52
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user