Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9fb5d1e9 | ||
|
|
62e6498b11 | ||
|
|
40dd26519d | ||
|
|
28da46ad05 | ||
|
|
49cdf67c5c | ||
|
|
2b6e1d73dc | ||
|
|
40debeea06 | ||
|
|
60a824d55a | ||
|
|
2897a4eaa4 | ||
|
|
c36a971125 | ||
|
|
f08f737b6f | ||
|
|
1161dbb129 | ||
|
|
5d4fc5334c | ||
|
|
f93494469d | ||
|
|
a94fcff758 | ||
|
|
1870724875 |
@@ -0,0 +1,52 @@
|
||||
# Search Improvements
|
||||
|
||||
Branch: `feature/search-improvements`
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Add playlists and audiobooks to search results
|
||||
- Parse from MA API response
|
||||
- Filter chips for each type
|
||||
- Tile builders with navigation to detail screens
|
||||
|
||||
- [x] Unified Relevance-Based "All" Results
|
||||
- Scoring algorithm implemented:
|
||||
- Exact match: 100 points
|
||||
- Starts with query: 80 points
|
||||
- Contains query (word boundary): 60 points
|
||||
- Contains query (anywhere): 40 points
|
||||
- Fuzzy match: 20 points
|
||||
- Bonus for library items (albums): +10 points
|
||||
- Bonus for favorites: +5 points
|
||||
- Secondary field matching (artist name in albums/tracks)
|
||||
- Merged all results into single sorted list
|
||||
- Type indicators in subtitle (e.g., "Artist • Album")
|
||||
|
||||
- [x] Smart Cross-Referencing
|
||||
- Extracts unique artists from matched tracks/albums
|
||||
- Filters to artists whose name contains query words (min 3 chars)
|
||||
- Adds cross-referenced artists with lower relevance score (25)
|
||||
- Avoids duplicates with direct search results
|
||||
|
||||
- [x] Past Searches Row
|
||||
- Added SearchHistory table to database (schema v4)
|
||||
- Saves successful searches (deduplicated, max 10)
|
||||
- Displays as ActionChips in empty state
|
||||
- Clicking chip performs that search
|
||||
|
||||
## Remaining Ideas (Lower Priority)
|
||||
|
||||
- **Library-only toggle:** Add switch to filter search to library items only (MA API supports this)
|
||||
- **Voice search:** Add microphone button if platform supports speech recognition
|
||||
- **Search suggestions:** Typeahead from library as user types
|
||||
- **Quick actions:** Long-press result for play/queue options without navigating
|
||||
- **Clear history in settings:** Add option to clear search history
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/database/database.dart` | Added SearchHistory table, schema v4 |
|
||||
| `lib/services/database_service.dart` | Added search history methods |
|
||||
| `lib/screens/search_screen.dart` | Relevance scoring, cross-refs, history UI |
|
||||
| `lib/l10n/app_en.arb` | Added localization strings |
|
||||
@@ -168,12 +168,24 @@ class HomeRowCache extends Table {
|
||||
Set<Column> get primaryKey => {rowType};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache])
|
||||
/// Search history for quick access to recent searches
|
||||
class SearchHistory extends Table {
|
||||
/// Auto-incrementing ID
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
/// The search query
|
||||
TextColumn get query => text()();
|
||||
|
||||
/// When the search was performed
|
||||
DateTimeColumn get searchedAt => dateTime()();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -192,6 +204,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 3) {
|
||||
await m.createTable(homeRowCache);
|
||||
}
|
||||
// Migration from v3 to v4: Add search history table
|
||||
if (from < 4) {
|
||||
await m.createTable(searchHistory);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -569,4 +585,51 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> clearHomeRowCache() async {
|
||||
await delete(homeRowCache).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Search History Operations
|
||||
// ============================================
|
||||
|
||||
/// Save a search query to history (deduplicates and limits to 10)
|
||||
Future<void> saveSearchQuery(String query) async {
|
||||
final trimmedQuery = query.trim();
|
||||
if (trimmedQuery.isEmpty) return;
|
||||
|
||||
await transaction(() async {
|
||||
// Delete existing entry with same query (case-insensitive)
|
||||
await (delete(searchHistory)
|
||||
..where((s) => s.query.lower().equals(trimmedQuery.toLowerCase())))
|
||||
.go();
|
||||
|
||||
// Insert the new search
|
||||
await into(searchHistory).insert(SearchHistoryCompanion.insert(
|
||||
query: trimmedQuery,
|
||||
searchedAt: DateTime.now(),
|
||||
));
|
||||
|
||||
// Keep only the 10 most recent searches
|
||||
final allSearches = await (select(searchHistory)
|
||||
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)]))
|
||||
.get();
|
||||
|
||||
if (allSearches.length > 10) {
|
||||
final toDelete = allSearches.skip(10).map((s) => s.id).toList();
|
||||
await (delete(searchHistory)..where((s) => s.id.isIn(toDelete))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get recent search queries (up to 10, most recent first)
|
||||
Future<List<String>> getRecentSearches() async {
|
||||
final searches = await (select(searchHistory)
|
||||
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)])
|
||||
..limit(10))
|
||||
.get();
|
||||
return searches.map((s) => s.query).toList();
|
||||
}
|
||||
|
||||
/// Clear all search history
|
||||
Future<void> clearSearchHistory() async {
|
||||
await delete(searchHistory).go();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"playerStateUnavailable": "Nicht verfügbar",
|
||||
"playerStateOff": "Aus",
|
||||
"playerStateIdle": "Inaktiv",
|
||||
"playerStateExternalSource": "Externe Quelle",
|
||||
"playerSelected": "Ausgewählt",
|
||||
"actionQueuedForSync": "Wird synchronisiert wenn online",
|
||||
"pendingOfflineActions": "{count} ausstehende Synchronisierung",
|
||||
|
||||
@@ -261,6 +261,26 @@
|
||||
"description": "Search hint text"
|
||||
},
|
||||
|
||||
"recentSearches": "Recent Searches",
|
||||
"@recentSearches": {
|
||||
"description": "Label for recent search history section"
|
||||
},
|
||||
|
||||
"clearSearchHistory": "Clear Search History",
|
||||
"@clearSearchHistory": {
|
||||
"description": "Button to clear search history"
|
||||
},
|
||||
|
||||
"searchHistoryCleared": "Search history cleared",
|
||||
"@searchHistoryCleared": {
|
||||
"description": "Confirmation message after clearing search history"
|
||||
},
|
||||
|
||||
"libraryOnly": "Library only",
|
||||
"@libraryOnly": {
|
||||
"description": "Toggle to search only in library"
|
||||
},
|
||||
|
||||
"retry": "Retry",
|
||||
"@retry": {
|
||||
"description": "Retry button label"
|
||||
@@ -610,6 +630,11 @@
|
||||
"description": "Radio button label"
|
||||
},
|
||||
|
||||
"addedToQueue": "Added to queue",
|
||||
"@addedToQueue": {
|
||||
"description": "Confirmation when item added to queue"
|
||||
},
|
||||
|
||||
"inLibrary": "In Library",
|
||||
"@inLibrary": {
|
||||
"description": "In library section title"
|
||||
@@ -1133,6 +1158,16 @@
|
||||
"description": "Singular form of audiobook"
|
||||
},
|
||||
|
||||
"albumSingular": "Album",
|
||||
"@albumSingular": {
|
||||
"description": "Singular form of album for search type indicator"
|
||||
},
|
||||
|
||||
"trackSingular": "Track",
|
||||
"@trackSingular": {
|
||||
"description": "Singular form of track for search type indicator"
|
||||
},
|
||||
|
||||
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
|
||||
"@trackCount": {
|
||||
"description": "Track count with plural",
|
||||
@@ -1193,6 +1228,11 @@
|
||||
"description": "Player state when idle"
|
||||
},
|
||||
|
||||
"playerStateExternalSource": "External Source",
|
||||
"@playerStateExternalSource": {
|
||||
"description": "Player state when playing from external source (optical, Spotify, etc.)"
|
||||
},
|
||||
|
||||
"playerSelected": "Selected",
|
||||
"@playerSelected": {
|
||||
"description": "Label for selected player"
|
||||
|
||||
@@ -475,6 +475,7 @@
|
||||
"playerStateUnavailable": "No disponible",
|
||||
"playerStateOff": "Apagado",
|
||||
"playerStateIdle": "Inactivo",
|
||||
"playerStateExternalSource": "Fuente Externa",
|
||||
"playerSelected": "Seleccionado",
|
||||
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
|
||||
"pendingOfflineActions": "{count} sincronización pendiente",
|
||||
|
||||
@@ -14,6 +14,8 @@ class Player {
|
||||
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
|
||||
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
|
||||
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
|
||||
final String? activeSource; // The currently active source for this player
|
||||
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
||||
|
||||
Player({
|
||||
required this.playerId,
|
||||
@@ -29,6 +31,8 @@ class Player {
|
||||
this.elapsedTimeLastUpdated,
|
||||
this.groupMembers,
|
||||
this.syncedTo,
|
||||
this.activeSource,
|
||||
this.isExternalSource = false,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
@@ -46,6 +50,8 @@ class Player {
|
||||
double? elapsedTimeLastUpdated,
|
||||
List<String>? groupMembers,
|
||||
String? syncedTo,
|
||||
String? activeSource,
|
||||
bool? isExternalSource,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
@@ -61,6 +67,8 @@ class Player {
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||
groupMembers: groupMembers ?? this.groupMembers,
|
||||
syncedTo: syncedTo ?? this.syncedTo,
|
||||
activeSource: activeSource ?? this.activeSource,
|
||||
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,6 +193,49 @@ class Player {
|
||||
// Parse synced_to - the player ID this player is synced to
|
||||
final syncedTo = json['synced_to'] as String?;
|
||||
|
||||
// Parse active_source - the currently active source for this player
|
||||
final activeSource = json['active_source'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify, AirPlay, etc.)
|
||||
// External sources have non-MA URIs and often media_type='unknown'
|
||||
bool isExternalSource = false;
|
||||
if (json.containsKey('current_media')) {
|
||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||
if (currentMedia != null) {
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// External source indicators:
|
||||
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
|
||||
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
|
||||
// 3. Media type is 'unknown' (MA doesn't recognize the source)
|
||||
if (uri != null) {
|
||||
final uriLower = uri.toLowerCase();
|
||||
// Simple external source identifiers (no :// or /)
|
||||
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||
// External streaming protocols
|
||||
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||
uriLower.startsWith('airplay://') ||
|
||||
uriLower.startsWith('cast://') ||
|
||||
uriLower.startsWith('bluetooth://');
|
||||
|
||||
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||
}
|
||||
|
||||
// Also check media_type - 'unknown' often indicates external source
|
||||
if (!isExternalSource && mediaType == 'unknown') {
|
||||
// If media_type is unknown and URI doesn't look like MA content, it's external
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||
isExternalSource = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Player(
|
||||
playerId: json['player_id'] as String,
|
||||
name: json['name'] as String,
|
||||
@@ -199,6 +250,8 @@ class Player {
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
||||
groupMembers: groupMembers,
|
||||
syncedTo: syncedTo,
|
||||
activeSource: activeSource,
|
||||
isExternalSource: isExternalSource,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +270,8 @@ class Player {
|
||||
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
||||
if (groupMembers != null) 'group_members': groupMembers,
|
||||
if (syncedTo != null) 'synced_to': syncedTo,
|
||||
if (activeSource != null) 'active_source': activeSource,
|
||||
'is_external_source': isExternalSource,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,7 +597,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
await _tryAdoptGhostPlayer();
|
||||
await _registerLocalPlayer();
|
||||
await _loadAndSelectPlayers();
|
||||
await _loadAndSelectPlayers(coldStart: true);
|
||||
|
||||
loadLibrary();
|
||||
|
||||
@@ -785,6 +785,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// This makes mini player and device button appear instantly on app resume
|
||||
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
|
||||
_availablePlayers = _cacheService.getCachedPlayers()!;
|
||||
|
||||
// Sort cached players immediately so list appears in correct order
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
|
||||
|
||||
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
|
||||
// Also try to restore from settings if cache doesn't have selected player
|
||||
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
|
||||
@@ -801,7 +807,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_selectedPlayer = _availablePlayers.first;
|
||||
}
|
||||
}
|
||||
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly');
|
||||
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
|
||||
notifyListeners(); // Update UI immediately with cached data
|
||||
}
|
||||
|
||||
@@ -1590,6 +1596,37 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (currentMedia != null) {
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
|
||||
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata
|
||||
bool isExternalSource = false;
|
||||
if (uri != null) {
|
||||
final uriLower = uri.toLowerCase();
|
||||
// Simple external source identifiers (no :// or /)
|
||||
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||
// External streaming protocols
|
||||
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||
uriLower.startsWith('airplay://') ||
|
||||
uriLower.startsWith('cast://') ||
|
||||
uriLower.startsWith('bluetooth://');
|
||||
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||
|
||||
// Also treat unknown media type with non-MA URIs as external
|
||||
if (!isExternalSource && mediaType == 'unknown' &&
|
||||
!uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||
isExternalSource = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternalSource) {
|
||||
_logger.log('📡 External source detected for $playerName (uri: $uri) - skipping metadata cache');
|
||||
// Clear cached track for this player to avoid showing stale data
|
||||
_cacheService.clearCachedTrackForPlayer(playerId);
|
||||
return; // Skip further processing for external sources
|
||||
}
|
||||
|
||||
// Clear audiobook context when switching to music (track) playback
|
||||
if (mediaType == 'track' && _currentAudiobook != null) {
|
||||
@@ -2058,27 +2095,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// SEARCH CACHING
|
||||
// ============================================================================
|
||||
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
||||
final cacheKey = query.toLowerCase().trim();
|
||||
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false, bool libraryOnly = false}) async {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
if (baseKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
|
||||
// Include libraryOnly in cache key to separate results
|
||||
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||
|
||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached search results for "$query"');
|
||||
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
|
||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Searching for "$query"...');
|
||||
final results = await _api!.search(query);
|
||||
_logger.log('🔄 Searching for "$query" (libraryOnly: $libraryOnly)...');
|
||||
final results = await _api!.search(query, libraryOnly: libraryOnly);
|
||||
|
||||
final cachedResults = <String, List<MediaItem>>{
|
||||
'artists': results['artists'] ?? [],
|
||||
'albums': results['albums'] ?? [],
|
||||
'tracks': results['tracks'] ?? [],
|
||||
'playlists': results['playlists'] ?? [],
|
||||
'audiobooks': results['audiobooks'] ?? [],
|
||||
};
|
||||
|
||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||
@@ -2086,7 +2128,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return cachedResults;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Search failed: $e');
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2121,7 +2163,38 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return await SettingsService.getBuiltinPlayerId();
|
||||
}
|
||||
|
||||
Future<void> _loadAndSelectPlayers({bool forceRefresh = false}) async {
|
||||
/// Sort players list based on smart sort setting
|
||||
/// Can be called synchronously with pre-fetched settings for cached players
|
||||
void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) {
|
||||
if (smartSort) {
|
||||
// Smart sort: local player first, then playing, then on, then off
|
||||
players.sort((a, b) {
|
||||
// Local player always first
|
||||
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||
if (aIsLocal && !bIsLocal) return -1;
|
||||
if (bIsLocal && !aIsLocal) return 1;
|
||||
|
||||
// Then by status: playing > on > off
|
||||
int statusPriority(Player p) {
|
||||
if (p.state == 'playing') return 0;
|
||||
if (p.powered && p.state != 'off') return 1;
|
||||
return 2;
|
||||
}
|
||||
final aPriority = statusPriority(a);
|
||||
final bPriority = statusPriority(b);
|
||||
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
||||
|
||||
// Within same status, sort alphabetically
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
// Default alphabetical sort
|
||||
players.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||
try {
|
||||
if (!forceRefresh &&
|
||||
_cacheService.isPlayersCacheValid() &&
|
||||
@@ -2253,34 +2326,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Sort players - check if smart sort is enabled
|
||||
// Sort players using helper method
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
if (smartSort) {
|
||||
// Smart sort: local player first, then playing, then on, then off
|
||||
_availablePlayers.sort((a, b) {
|
||||
// Local player always first
|
||||
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||
if (aIsLocal && !bIsLocal) return -1;
|
||||
if (bIsLocal && !aIsLocal) return 1;
|
||||
|
||||
// Then by status: playing > on > off
|
||||
int statusPriority(Player p) {
|
||||
if (p.state == 'playing') return 0;
|
||||
if (p.powered && p.state != 'off') return 1;
|
||||
return 2;
|
||||
}
|
||||
final aPriority = statusPriority(a);
|
||||
final bPriority = statusPriority(b);
|
||||
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
||||
|
||||
// Within same status, sort alphabetically
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
} else {
|
||||
// Default alphabetical sort
|
||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
}
|
||||
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
|
||||
|
||||
// Cache players for instant display on app resume
|
||||
_cacheService.setCachedPlayers(_availablePlayers);
|
||||
@@ -2310,7 +2358,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Keep currently selected player if still available (and not overridden by prefer local)
|
||||
// But allow switching to a playing player when preferLocalPlayer is OFF
|
||||
if (playerToSelect == null && _selectedPlayer != null) {
|
||||
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
|
||||
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
||||
final stillAvailable = _availablePlayers.any(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||
);
|
||||
@@ -2341,6 +2390,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
if (coldStart) {
|
||||
_logger.log('🚀 Cold start: applying full priority logic (playing > local > last selected)');
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
// Smart auto-selection priority:
|
||||
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
|
||||
@@ -3367,6 +3420,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addTrackToQueue(String playerId, Track track) async {
|
||||
try {
|
||||
await _api?.addTrackToQueue(playerId, track);
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Add to queue');
|
||||
_error = errorInfo.userMessage;
|
||||
ErrorHandler.logError('Add to queue', e);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
|
||||
|
||||
@@ -363,9 +363,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final api = maProvider.api;
|
||||
if (api == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For adding to queue with radio mode, we use radio_mode but with 'add' option
|
||||
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
||||
await api.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
+800
-66
File diff suppressed because it is too large
Load Diff
@@ -259,6 +259,11 @@ class CacheService {
|
||||
_playerTrackCache[playerId] = track;
|
||||
}
|
||||
|
||||
/// Clear cached track for a player (e.g., when external source is active)
|
||||
void clearCachedTrackForPlayer(String playerId) {
|
||||
_playerTrackCache.remove(playerId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEAR ALL
|
||||
// ============================================================================
|
||||
|
||||
@@ -239,4 +239,17 @@ class DatabaseService {
|
||||
|
||||
/// Clear all home row cache
|
||||
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
|
||||
|
||||
// ============================================
|
||||
// Search History Convenience Methods
|
||||
// ============================================
|
||||
|
||||
/// Save a search query to history
|
||||
Future<void> saveSearchQuery(String query) => db.saveSearchQuery(query);
|
||||
|
||||
/// Get recent search queries (up to 10)
|
||||
Future<List<String>> getRecentSearches() => db.getRecentSearches();
|
||||
|
||||
/// Clear all search history
|
||||
Future<void> clearSearchHistory() => db.clearSearchHistory();
|
||||
}
|
||||
|
||||
@@ -1399,7 +1399,7 @@ class MusicAssistantAPI {
|
||||
|
||||
final result = searchResponse['result'] as Map<String, dynamic>?;
|
||||
if (result == null) {
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
}
|
||||
|
||||
// Parse results from the search response
|
||||
@@ -1418,6 +1418,16 @@ class MusicAssistantAPI {
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final playlists = (result['playlists'] as List<dynamic>?)
|
||||
?.map((item) => Playlist.fromJson(item as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final audiobooks = (result['audiobooks'] as List<dynamic>?)
|
||||
?.map((item) => Audiobook.fromJson(item as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
// Deduplicate results by name
|
||||
// MA returns both library items and provider items for the same content
|
||||
// Prefer library items (provider='library') as they have all provider mappings
|
||||
@@ -1425,15 +1435,17 @@ class MusicAssistantAPI {
|
||||
'artists': _deduplicateResults(artists),
|
||||
'albums': _deduplicateResults(albums),
|
||||
'tracks': _deduplicateResults(tracks),
|
||||
'playlists': _deduplicateResults(playlists),
|
||||
'audiobooks': _deduplicateResults(audiobooks),
|
||||
};
|
||||
} catch (e) {
|
||||
_logger.log('Error searching: $e');
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
}
|
||||
},
|
||||
).catchError((e) {
|
||||
_logger.log('Error searching after retries: $e');
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1602,6 +1614,25 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a single track to the queue without starting playback
|
||||
Future<void> addTrackToQueue(String playerId, Track track) async {
|
||||
try {
|
||||
final uri = _buildTrackUri(track);
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri],
|
||||
'option': 'add', // Add to queue without starting
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error adding track to queue: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Play multiple tracks via queue
|
||||
/// If clearQueue is true, replaces the queue (default behavior)
|
||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||
|
||||
@@ -202,17 +202,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
// but still have track info - treat as "has content" for display
|
||||
final isIdle = player.state == 'idle';
|
||||
|
||||
// Check for external source (optical, Spotify, etc.)
|
||||
final isExternalSource = player.isExternalSource;
|
||||
|
||||
// Get track info - use current track for selected player, cache for others
|
||||
final playerTrack = isSelected
|
||||
? currentTrack
|
||||
: maProvider.getCachedTrackForPlayer(player.playerId);
|
||||
// Skip track info for external sources
|
||||
final playerTrack = isExternalSource
|
||||
? null
|
||||
: (isSelected
|
||||
? currentTrack
|
||||
: maProvider.getCachedTrackForPlayer(player.playerId));
|
||||
|
||||
// Player has content if playing, paused, or idle with cached track
|
||||
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null);
|
||||
// Player has content if playing, paused, or idle with cached track (but not external source)
|
||||
final hasContent = !isExternalSource && (isPlaying || isPaused || (isIdle && playerTrack != null));
|
||||
|
||||
// Get album art for any player with content
|
||||
// Get album art for any player with content (skip for external source)
|
||||
String? albumArtUrl;
|
||||
if (playerTrack != null && isOn && hasContent) {
|
||||
if (!isExternalSource && playerTrack != null && isOn && hasContent) {
|
||||
albumArtUrl = maProvider.getImageUrl(
|
||||
playerTrack.album ?? playerTrack,
|
||||
size: 128,
|
||||
@@ -321,11 +327,13 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
? colorScheme.onSurface.withOpacity(0.24)
|
||||
: !isOn
|
||||
? colorScheme.onSurface.withOpacity(0.3)
|
||||
: isPlaying
|
||||
? Colors.green
|
||||
: hasContent
|
||||
? Colors.orange // paused or idle with content
|
||||
: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
: isExternalSource
|
||||
? Colors.cyan // external source (optical, Spotify, etc.)
|
||||
: isPlaying
|
||||
? Colors.green
|
||||
: hasContent
|
||||
? Colors.orange // paused or idle with content
|
||||
: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
@@ -335,9 +343,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
? S.of(context)!.playerStateUnavailable
|
||||
: !isOn
|
||||
? S.of(context)!.playerStateOff
|
||||
: hasContent && playerTrack != null
|
||||
? playerTrack.name
|
||||
: S.of(context)!.playerStateIdle,
|
||||
: isExternalSource
|
||||
? S.of(context)!.playerStateExternalSource
|
||||
: hasContent && playerTrack != null
|
||||
? playerTrack.name
|
||||
: S.of(context)!.playerStateIdle,
|
||||
style: TextStyle(
|
||||
color: player.available
|
||||
? colorScheme.onSurfaceVariant
|
||||
@@ -400,8 +410,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Play/Pause button
|
||||
if (player.available && isOn)
|
||||
// Play/Pause button (hidden for external sources - controls don't work)
|
||||
if (player.available && isOn && !isExternalSource)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.6.1-beta+29
|
||||
version: 2.7.0-beta+30
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user