Compare commits

...
2 Commits
8 changed files with 1196 additions and 482 deletions
+63
View File
@@ -58,6 +58,10 @@ class MediaItem {
final bool? favorite;
final int? position;
final Duration? duration;
final DateTime? timestampAdded;
final DateTime? timestampModified;
final DateTime? lastPlayed;
final int? playCount;
MediaItem({
required this.itemId,
@@ -71,6 +75,10 @@ class MediaItem {
this.favorite,
this.position,
this.duration,
this.timestampAdded,
this.timestampModified,
this.lastPlayed,
this.playCount,
});
factory MediaItem.fromJson(Map<String, dynamic> json) {
@@ -83,6 +91,21 @@ class MediaItem {
);
}
// Parse timestamp fields (can be int unix timestamp or ISO string)
DateTime? parseTimestamp(dynamic value) {
if (value == null) return null;
if (value is int) {
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
}
if (value is double) {
return DateTime.fromMillisecondsSinceEpoch((value * 1000).toInt());
}
if (value is String) {
return DateTime.tryParse(value);
}
return null;
}
return MediaItem(
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
provider: json['provider'] as String? ?? 'unknown',
@@ -99,6 +122,10 @@ class MediaItem {
duration: json['duration'] != null
? Duration(seconds: (json['duration'] as num).toInt())
: null,
timestampAdded: parseTimestamp(json['timestamp_added']),
timestampModified: parseTimestamp(json['timestamp_modified']),
lastPlayed: parseTimestamp(json['last_played']),
playCount: json['play_count'] as int?,
);
}
@@ -116,6 +143,10 @@ class MediaItem {
if (favorite != null) 'favorite': favorite,
if (position != null) 'position': position,
if (duration != null) 'duration': duration!.inSeconds,
if (timestampAdded != null) 'timestamp_added': timestampAdded!.millisecondsSinceEpoch ~/ 1000,
if (timestampModified != null) 'timestamp_modified': timestampModified!.millisecondsSinceEpoch ~/ 1000,
if (lastPlayed != null) 'last_played': lastPlayed!.millisecondsSinceEpoch ~/ 1000,
if (playCount != null) 'play_count': playCount,
};
}
}
@@ -130,6 +161,10 @@ class Artist extends MediaItem {
super.providerMappings,
super.metadata,
super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.artist);
factory Artist.fromJson(Map<String, dynamic> json) {
@@ -143,6 +178,10 @@ class Artist extends MediaItem {
providerMappings: item.providerMappings,
metadata: item.metadata,
favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
@@ -167,6 +206,10 @@ class Album extends MediaItem {
super.providerMappings,
super.metadata,
super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.album);
factory Album.fromJson(Map<String, dynamic> json) {
@@ -194,6 +237,10 @@ class Album extends MediaItem {
providerMappings: item.providerMappings,
metadata: item.metadata,
favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
@@ -240,6 +287,10 @@ class Track extends MediaItem {
super.favorite,
super.position,
super.duration,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.track);
factory Track.fromJson(Map<String, dynamic> json) {
@@ -261,6 +312,10 @@ class Track extends MediaItem {
favorite: item.favorite,
position: item.position,
duration: item.duration,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
@@ -295,6 +350,10 @@ class Playlist extends MediaItem {
super.providerMappings,
super.metadata,
super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.playlist);
factory Playlist.fromJson(Map<String, dynamic> json) {
@@ -311,6 +370,10 @@ class Playlist extends MediaItem {
providerMappings: item.providerMappings,
metadata: item.metadata,
favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
}
+214 -89
View File
@@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../models/media_item.dart';
import '../models/music_provider.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
@@ -106,9 +107,9 @@ class MusicAssistantProvider with ChangeNotifier {
// This is populated with episode covers when podcast covers are low-res
final Map<String, String> _podcastCoverCache = {};
// Provider filter: list of allowed provider instance IDs from MA user settings
// Empty list means all providers are allowed
List<String> _providerFilter = [];
// Provider filter: list of DISABLED provider instance IDs from local app settings
// Empty list means all providers are enabled (shown)
List<String> _disabledProviders = [];
// Player filter: list of allowed player IDs from MA user settings
// Empty list means all players are allowed
@@ -125,8 +126,14 @@ class MusicAssistantProvider with ChangeNotifier {
_connectionState == MAConnectionState.authenticated;
// Library getters with provider filtering applied
List<Artist> get artists => filterByProvider(_artists);
List<Album> get albums => filterByProvider(_albums);
List<Artist> get artists {
_logger.log('🔍 artists getter: ${_artists.length} items, disabled=$_disabledProviders');
return filterByProvider(_artists);
}
List<Album> get albums {
_logger.log('🔍 albums getter: ${_albums.length} items, disabled=$_disabledProviders');
return filterByProvider(_albums);
}
List<Track> get tracks => filterByProvider(_tracks);
List<MediaItem> get radioStations => filterByProvider(_radioStations);
List<MediaItem> get podcasts => filterByProvider(_podcasts);
@@ -138,54 +145,145 @@ class MusicAssistantProvider with ChangeNotifier {
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
/// Provider filter from MA user settings (empty = all providers allowed)
List<String> get providerFilter => _providerFilter;
/// Available music providers from MA server
List<MusicProvider> get musicProviders => _api?.musicProviders ?? [];
/// Whether provider filtering is active
bool get hasProviderFilter => _providerFilter.isNotEmpty;
/// List of disabled provider instance IDs (from local app settings)
List<String> get disabledProviders => _disabledProviders;
/// Whether provider filtering is active (any provider disabled)
bool get hasProviderFilter => _disabledProviders.isNotEmpty;
/// Infrastructure providers that should be ignored for library filtering
static const _infrastructureProviders = {'builtin', 'radiobrowser', 'itunes_podcasts', 'audiobookshelf'};
/// Get the single selected provider for server-side filtering
/// Returns the provider instance ID if exactly one "real" music provider is enabled
/// Returns null if no filtering needed or multiple providers enabled
String? get selectedProviderForFilter {
if (_disabledProviders.isEmpty) return null;
// Get all available music providers from the server
final allProviders = musicProviders;
if (allProviders.isEmpty) return null;
// Find enabled "real" music providers (not infrastructure)
final enabledRealProviders = allProviders.where((p) {
// Skip infrastructure providers
if (_infrastructureProviders.contains(p.instanceId) ||
_infrastructureProviders.any((infra) => p.instanceId.startsWith(infra))) {
return false;
}
// Check if this provider is enabled (not in disabled list)
return !_disabledProviders.contains(p.instanceId);
}).toList();
// If exactly one real provider is enabled, use it for server-side filtering
if (enabledRealProviders.length == 1) {
final provider = enabledRealProviders.first.instanceId;
_logger.log('🔌 Selected provider for filter: $provider');
return provider;
}
_logger.log('🔌 No single provider selected: ${enabledRealProviders.length} enabled');
return null;
}
/// Check if a media item should be visible based on provider filter
/// Returns true if:
/// - No filter is active (empty list = all providers allowed)
/// - The item has at least one provider mapping in the allowed list
/// - The item's primary provider is in the allowed list
/// Returns true if item has at least one ENABLED provider
/// Hide only if ALL of the item's providers are disabled
int _filterDebugCount = 0;
bool isItemAllowedByProviderFilter(MediaItem item) {
// No filter = show everything
if (_providerFilter.isEmpty) return true;
// No disabled providers = show everything
if (_disabledProviders.isEmpty) return true;
// Check if item's provider mappings include any allowed provider
final mappings = item.providerMappings;
if (mappings != null && mappings.isNotEmpty) {
for (final mapping in mappings) {
if (_providerFilter.contains(mapping.providerInstance)) {
return true;
// Get real provider mappings (exclude "None" which is MA's internal library)
final realMappings = mappings
.where((m) => m.providerInstance != 'None' && m.providerInstance.isNotEmpty)
.toList();
// If no real mappings, show the item (only has internal library reference)
if (realMappings.isEmpty) {
if (_filterDebugCount < 10) {
_logger.log('🔍 ALLOWED (no real mappings): "${item.name}"');
_filterDebugCount++;
}
return true;
}
// Show if ANY provider is enabled (not in disabled list)
final enabledMappings = realMappings.where((m) => !_disabledProviders.contains(m.providerInstance)).toList();
final allowed = enabledMappings.isNotEmpty;
if (_filterDebugCount < 10) {
final allProviders = realMappings.map((m) => m.providerInstance).toList();
final enabledProviders = enabledMappings.map((m) => m.providerInstance).toList();
_logger.log('🔍 ${allowed ? "ALLOWED" : "FILTERED"}: "${item.name}" providers=$allProviders enabled=$enabledProviders');
_filterDebugCount++;
}
return allowed;
}
// Also check primary provider field (for items without full mappings)
if (_providerFilter.contains(item.provider)) {
return true;
// Check primary provider field (for items without full mappings)
final allowed = !_disabledProviders.contains(item.provider);
if (_filterDebugCount < 10) {
_logger.log('🔍 ${allowed ? "ALLOWED" : "FILTERED"} (no mappings): "${item.name}" provider=${item.provider}');
_filterDebugCount++;
}
return false;
return allowed;
}
/// Filter a list of media items based on provider filter
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
if (_providerFilter.isEmpty) return items;
return items.where(isItemAllowedByProviderFilter).toList();
if (_disabledProviders.isEmpty) return items;
_filterDebugCount = 0; // Reset for fresh logging
final filtered = items.where(isItemAllowedByProviderFilter).toList();
_logger.log('🔌 filterByProvider: ${items.length} → ${filtered.length} (disabled=$_disabledProviders)');
return filtered;
}
/// Filter search results map based on provider filter
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
if (_providerFilter.isEmpty) return results;
if (_disabledProviders.isEmpty) return results;
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
};
}
/// Load disabled providers from local settings
Future<void> loadDisabledProviders() async {
_disabledProviders = await SettingsService.getDisabledMusicProviders();
_logger.log('🔌 Loaded disabled providers: $_disabledProviders');
notifyListeners();
}
/// Toggle a provider's enabled state and persist
/// Clears caches and forces a fresh sync with the new filter
Future<void> toggleProviderEnabled(String instanceId, bool enabled) async {
_logger.log('🔌 Toggle provider $instanceId enabled=$enabled');
await SettingsService.toggleProviderEnabled(instanceId, enabled);
await loadDisabledProviders();
// Clear all caches so fresh filtered data is fetched
_cacheService.invalidateHomeCache();
_cacheService.invalidateSearchCache();
await SyncService.instance.clearCache();
_logger.log('🔌 After toggle, disabled=$_disabledProviders, selectedFilter=${selectedProviderForFilter}');
// Force a fresh sync with the new provider filter
if (_api != null) {
_logger.log('🔌 Forcing library re-sync with provider filter...');
await SyncService.instance.forceSync(_api!, provider: selectedProviderForFilter);
_albums = SyncService.instance.cachedAlbums;
_artists = SyncService.instance.cachedArtists;
notifyListeners();
}
}
/// Player filter from MA user settings (empty = all players allowed)
List<String> get playerFilter => _playerFilter;
@@ -916,22 +1014,11 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('✅ Set owner name from MA profile: $profileName');
}
// Capture provider filter (empty list = all providers allowed)
final providerFilterRaw = userInfo['provider_filter'];
if (providerFilterRaw is List) {
_providerFilter = providerFilterRaw.cast<String>().toList();
if (_providerFilter.isNotEmpty) {
_logger.log('🔒 Provider filter active: ${_providerFilter.length} providers allowed');
_logger.log(' Allowed: ${_providerFilter.join(", ")}');
} else {
_logger.log('🔓 No provider filter - all providers visible');
}
} else {
_providerFilter = [];
_logger.log('🔓 No provider filter in user settings');
}
// Load provider filter from LOCAL app settings (not MA user profile)
// MA user profile provider_filter is ignored as it causes issues with recents
await loadDisabledProviders();
// Capture player filter (empty list = all players allowed)
// Capture player filter from MA user settings (empty list = all players allowed)
final playerFilterRaw = userInfo['player_filter'];
if (playerFilterRaw is List) {
_playerFilter = playerFilterRaw.cast<String>().toList();
@@ -2720,38 +2807,46 @@ class MusicAssistantProvider with ChangeNotifier {
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
return filterByProvider(_cacheService.getCachedDiscoverArtists()!);
}
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
if (_api == null) {
final cached = _cacheService.getCachedDiscoverArtists();
return cached != null ? filterByProvider(cached) : [];
}
try {
_logger.log('🔄 Fetching fresh discover artists...');
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverArtists(artists);
return artists;
return filterByProvider(artists);
} catch (e) {
_logger.log('❌ Failed to fetch discover artists: $e');
return _cacheService.getCachedDiscoverArtists() ?? [];
final cached = _cacheService.getCachedDiscoverArtists();
return cached != null ? filterByProvider(cached) : [];
}
}
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
return filterByProvider(_cacheService.getCachedDiscoverAlbums()!);
}
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
if (_api == null) {
final cached = _cacheService.getCachedDiscoverAlbums();
return cached != null ? filterByProvider(cached) : [];
}
try {
_logger.log('🔄 Fetching fresh discover albums...');
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverAlbums(albums);
return albums;
return filterByProvider(albums);
} catch (e) {
_logger.log('❌ Failed to fetch discover albums: $e');
return _cacheService.getCachedDiscoverAlbums() ?? [];
final cached = _cacheService.getCachedDiscoverAlbums();
return cached != null ? filterByProvider(cached) : [];
}
}
@@ -2759,21 +2854,31 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateHomeCache();
}
/// Get cached recent albums synchronously (for instant display)
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
/// Get cached recent albums synchronously (for instant display, with filtering)
List<Album>? getCachedRecentAlbums() {
final cached = _cacheService.getCachedRecentAlbums();
return cached != null ? filterByProvider(cached) : null;
}
/// Get cached discover artists synchronously (for instant display)
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
/// Get cached discover artists synchronously (for instant display, with filtering)
List<Artist>? getCachedDiscoverArtists() {
final cached = _cacheService.getCachedDiscoverArtists();
return cached != null ? filterByProvider(cached) : null;
}
/// Get cached discover albums synchronously (for instant display)
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
/// Get cached discover albums synchronously (for instant display, with filtering)
List<Album>? getCachedDiscoverAlbums() {
final cached = _cacheService.getCachedDiscoverAlbums();
return cached != null ? filterByProvider(cached) : null;
}
/// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async {
if (_api == null) return;
_logger.log('🔄 Forcing full library sync...');
await SyncService.instance.forceSync(_api!);
final providerFilter = selectedProviderForFilter;
_logger.log('🔄 Forcing full library sync (provider=$providerFilter)...');
await SyncService.instance.forceSync(_api!, provider: providerFilter);
// Update local lists from sync result
_albums = SyncService.instance.cachedAlbums;
@@ -2794,32 +2899,36 @@ class MusicAssistantProvider with ChangeNotifier {
// FAVORITES FOR HOME SCREEN
// ============================================================================
/// Get favorite albums from the library
/// Get favorite albums from the library (with provider filtering)
Future<List<Album>> getFavoriteAlbums() async {
// Filter from loaded library - favorites are already loaded
return _albums.where((a) => a.favorite == true).toList();
// Use filtered albums to respect provider settings
return albums.where((a) => a.favorite == true).toList();
}
/// Get favorite artists from the library
/// Get favorite artists from the library (with provider filtering)
Future<List<Artist>> getFavoriteArtists() async {
// Filter from loaded library - favorites are already loaded
return _artists.where((a) => a.favorite == true).toList();
// Use filtered artists to respect provider settings
return artists.where((a) => a.favorite == true).toList();
}
/// Get favorite tracks from the library
/// Get favorite tracks from the library (with provider filtering)
Future<List<Track>> getFavoriteTracks() async {
// If full tracks list is loaded, filter from it
// Use filtered tracks to respect provider settings
if (_tracks.isNotEmpty) {
final favTracks = _tracks.where((t) => t.favorite == true).toList();
// Update cache if we have new data
if (favTracks.isNotEmpty && favTracks.length != _cachedFavoriteTracks.length) {
_cachedFavoriteTracks = favTracks;
_persistFavoriteTracks(favTracks);
final favTracks = tracks.where((t) => t.favorite == true).toList();
// Update cache if we have new data (using unfiltered for persistence)
final unfilteredFavTracks = _tracks.where((t) => t.favorite == true).toList();
if (unfilteredFavTracks.isNotEmpty && unfilteredFavTracks.length != _cachedFavoriteTracks.length) {
_cachedFavoriteTracks = unfilteredFavTracks;
_persistFavoriteTracks(unfilteredFavTracks);
}
return favTracks;
}
// Otherwise return cached favorites (for instant display before full library loads)
return _cachedFavoriteTracks;
return filterByProvider(_cachedFavoriteTracks);
}
/// Get favorite playlists from the library
@@ -2866,13 +2975,13 @@ class MusicAssistantProvider with ChangeNotifier {
// Check cache first
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
_logger.log('📦 Using cached in-progress audiobooks');
return _cacheService.getCachedInProgressAudiobooks()!;
return filterByProvider(_cacheService.getCachedInProgressAudiobooks()!);
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
return [];
}
@@ -2892,26 +3001,29 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
return [];
}
}
/// Get cached in-progress audiobooks synchronously (for instant display)
List<Audiobook>? getCachedInProgressAudiobooks() => _cacheService.getCachedInProgressAudiobooks();
/// Get cached in-progress audiobooks synchronously (for instant display, with filtering)
List<Audiobook>? getCachedInProgressAudiobooks() {
final cached = _cacheService.getCachedInProgressAudiobooks();
return cached != null ? filterByProvider(cached) : null;
}
/// Get random audiobooks for discovery with caching
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
_logger.log('📦 Using cached discover audiobooks');
return _cacheService.getCachedDiscoverAudiobooks()!;
return filterByProvider(_cacheService.getCachedDiscoverAudiobooks()!);
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
return [];
}
@@ -2928,13 +3040,16 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('❌ Failed to fetch discover audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
return [];
}
}
/// Get cached discover audiobooks synchronously (for instant display)
List<Audiobook>? getCachedDiscoverAudiobooks() => _cacheService.getCachedDiscoverAudiobooks();
/// Get cached discover audiobooks synchronously (for instant display, with filtering)
List<Audiobook>? getCachedDiscoverAudiobooks() {
final cached = _cacheService.getCachedDiscoverAudiobooks();
return cached != null ? filterByProvider(cached) : null;
}
/// Get random series for discovery with caching
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
@@ -4402,10 +4517,15 @@ class MusicAssistantProvider with ChangeNotifier {
}
// Fetch tracks from API (not cached - too many items)
// Apply provider filter if a single provider is selected
if (_api != null) {
try {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
final providerFilter = selectedProviderForFilter;
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
provider: providerFilter,
);
_logger.log('📥 Fetched ${_tracks.length} tracks from MA (provider=$providerFilter)');
} catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e');
}
@@ -4431,6 +4551,8 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return;
final syncService = SyncService.instance;
final providerFilter = selectedProviderForFilter;
_logger.log('🔄 _syncLibraryInBackground starting with providerFilter=$providerFilter');
// Listen for sync completion to update our lists
void onSyncComplete() {
@@ -4444,18 +4566,18 @@ class MusicAssistantProvider with ChangeNotifier {
}
syncService.addListener(onSyncComplete);
await syncService.syncFromApi(_api!);
await syncService.syncFromApi(_api!, provider: providerFilter);
}
/// Load radio stations from the library
Future<void> loadRadioStations() async {
Future<void> loadRadioStations({String? orderBy}) async {
if (!isConnected || _api == null) return;
try {
_isLoadingRadio = true;
notifyListeners();
_radioStations = await _api!.getRadioStations(limit: 100);
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
_isLoadingRadio = false;
notifyListeners();
} catch (e) {
@@ -4465,14 +4587,14 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> loadPodcasts() async {
Future<void> loadPodcasts({String? orderBy}) async {
if (!isConnected || _api == null) return;
try {
_isLoadingPodcasts = true;
notifyListeners();
_podcasts = await _api!.getPodcasts(limit: 100);
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
_isLoadingPodcasts = false;
@@ -4515,7 +4637,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
if (!isConnected) return;
try {
@@ -4528,6 +4650,7 @@ class MusicAssistantProvider with ChangeNotifier {
offset: offset,
search: search,
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
orderBy: orderBy,
);
_isLoading = false;
@@ -4545,6 +4668,7 @@ class MusicAssistantProvider with ChangeNotifier {
int? offset,
String? search,
String? artistId,
String? orderBy,
}) async {
if (!isConnected) return;
@@ -4558,6 +4682,7 @@ class MusicAssistantProvider with ChangeNotifier {
offset: offset,
search: search,
artistId: artistId,
orderBy: orderBy,
);
_isLoading = false;
@@ -4596,10 +4721,10 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Get playlists with provider filtering applied
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly}) async {
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
if (_api == null) return [];
try {
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly);
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
return filterByProvider(playlists);
} catch (e) {
_logger.log('❌ Failed to fetch playlists: $e');
File diff suppressed because it is too large Load Diff
+50 -14
View File
@@ -145,6 +145,9 @@ class SearchScreenState extends State<SearchScreen> {
double _highlightLeft = 0;
double _highlightWidth = 80; // Default width until measured
// Track provider filter to detect changes
List<String>? _lastDisabledProviders;
@override
void initState() {
super.initState();
@@ -153,18 +156,48 @@ class SearchScreenState extends State<SearchScreen> {
_loadRecentSearches();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Check if provider filter changed and clear caches if so
final provider = context.read<MusicAssistantProvider>();
final currentDisabled = provider.disabledProviders;
if (_lastDisabledProviders != null &&
!_listEquals(_lastDisabledProviders!, currentDisabled)) {
_cachedAvailableFilters = null;
_cachedListItems.clear();
}
_lastDisabledProviders = List.from(currentDisabled);
}
bool _listEquals(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
/// Get filtered search results based on provider filter settings
Map<String, List<MediaItem>> _getFilteredResults(MusicAssistantProvider provider) {
return provider.filterSearchResults(_searchResults);
}
/// Get list of available filters based on current results (cached)
/// Uses provider filter to exclude disabled providers from results
List<String> _getAvailableFilters() {
if (_cachedAvailableFilters != null) return _cachedAvailableFilters!;
final provider = context.read<MusicAssistantProvider>();
final filtered = _getFilteredResults(provider);
final filters = <String>['all'];
if (_searchResults['artists']?.isNotEmpty == true) filters.add('artists');
if (_searchResults['albums']?.isNotEmpty == true) filters.add('albums');
if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks');
if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists');
if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
if (_searchResults['radios']?.isNotEmpty == true) filters.add('radios');
if (_searchResults['podcasts']?.isNotEmpty == true) filters.add('podcasts');
if (filtered['artists']?.isNotEmpty == true) filters.add('artists');
if (filtered['albums']?.isNotEmpty == true) filters.add('albums');
if (filtered['tracks']?.isNotEmpty == true) filters.add('tracks');
if (filtered['playlists']?.isNotEmpty == true) filters.add('playlists');
if (filtered['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
if (filtered['radios']?.isNotEmpty == true) filters.add('radios');
if (filtered['podcasts']?.isNotEmpty == true) filters.add('podcasts');
_cachedAvailableFilters = filters;
return filters;
}
@@ -691,13 +724,16 @@ class SearchScreenState extends State<SearchScreen> {
);
}
final artists = _searchResults['artists'] as List<MediaItem>? ?? [];
final albums = _searchResults['albums'] as List<MediaItem>? ?? [];
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
final playlists = _searchResults['playlists'] as List<MediaItem>? ?? [];
final audiobooks = _searchResults['audiobooks'] as List<MediaItem>? ?? [];
final radios = _searchResults['radios'] as List<MediaItem>? ?? [];
final podcasts = _searchResults['podcasts'] as List<MediaItem>? ?? [];
// Apply provider filter to search results
final provider = context.read<MusicAssistantProvider>();
final filtered = _getFilteredResults(provider);
final artists = filtered['artists'] as List<MediaItem>? ?? [];
final albums = filtered['albums'] as List<MediaItem>? ?? [];
final tracks = filtered['tracks'] as List<MediaItem>? ?? [];
final playlists = filtered['playlists'] as List<MediaItem>? ?? [];
final audiobooks = filtered['audiobooks'] as List<MediaItem>? ?? [];
final radios = filtered['radios'] as List<MediaItem>? ?? [];
final podcasts = filtered['podcasts'] as List<MediaItem>? ?? [];
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty ||
playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty ||
+66
View File
@@ -49,6 +49,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showHints = true;
// Library settings
bool _showOnlyArtistsWithAlbums = false;
// Music provider filter settings
List<String> _disabledProviders = [];
@override
void initState() {
@@ -107,6 +109,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load library settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
// Load music provider filter settings
final disabledProviders = await SettingsService.getDisabledMusicProviders();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -129,6 +134,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
_disabledProviders = disabledProviders;
});
}
}
@@ -705,6 +711,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Music Providers section
if (provider.musicProviders.isNotEmpty) ...[
Text(
'Music Providers',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Filter content shown in Home, Library, and Search',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
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: Column(
children: provider.musicProviders.map((musicProvider) {
final isEnabled = !_disabledProviders.contains(musicProvider.instanceId);
return SwitchListTile(
title: Text(
musicProvider.name,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: musicProvider.isStreamingProvider
? Text(
'Streaming',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
)
: null,
value: isEnabled,
onChanged: (value) async {
await provider.toggleProviderEnabled(musicProvider.instanceId, value);
setState(() {
if (value) {
_disabledProviders.remove(musicProvider.instanceId);
} else {
_disabledProviders.add(musicProvider.instanceId);
}
});
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
);
}).toList(),
),
),
const SizedBox(height: 32),
],
// Player section
Text(
S.of(context)!.players,
+55 -13
View File
@@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
import '../constants/network.dart';
import '../constants/timings.dart' show Timings, LibraryConstants;
import '../models/media_item.dart';
import '../models/music_provider.dart';
import '../models/player.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
@@ -52,6 +53,10 @@ class MusicAssistantAPI {
bool _authRequired = false;
int? _schemaVersion;
// Music providers (type == "music")
List<MusicProvider> _musicProviders = [];
List<MusicProvider> get musicProviders => _musicProviders;
// Auth state
bool _isAuthenticated = false;
bool get isAuthenticated => _isAuthenticated;
@@ -376,23 +381,30 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
bool albumArtistsOnly = true,
String? orderBy,
String? provider, // Filter to specific provider instance
}) async {
try {
final args = {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
'album_artists_only': albumArtistsOnly,
if (orderBy != null) 'order_by': orderBy,
if (provider != null) 'provider': provider,
};
_logger.log('🔍 API getArtists args: $args');
final response = await _sendCommand(
'music/artists/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
'album_artists_only': albumArtistsOnly,
},
args: args,
);
final items = response['result'] as List<dynamic>?;
if (items == null) {
return [];
}
_logger.log('🔍 API getArtists returned ${items.length} items');
return items
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
.toList();
@@ -402,7 +414,7 @@ class MusicAssistantAPI {
}
}
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true, String? provider}) async {
try {
final response = await _sendCommand(
'music/artists/library_items',
@@ -410,6 +422,7 @@ class MusicAssistantAPI {
'limit': limit,
'order_by': 'random',
'album_artists_only': albumArtistsOnly,
if (provider != null) 'provider': provider,
},
);
@@ -431,6 +444,8 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
String? artistId,
String? orderBy,
String? provider, // Filter to specific provider instance
}) async {
try {
final args = <String, dynamic>{
@@ -439,7 +454,10 @@ class MusicAssistantAPI {
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist_id': artistId,
if (orderBy != null) 'order_by': orderBy,
if (provider != null) 'provider': provider,
};
_logger.log('🔍 API getAlbums args: $args');
final response = await _sendCommand(
'music/albums/library_items',
@@ -448,6 +466,7 @@ class MusicAssistantAPI {
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
_logger.log('🔍 API getAlbums returned ${items.length} items');
return items
.map((item) => Album.fromJson(item as Map<String, dynamic>))
@@ -465,6 +484,8 @@ class MusicAssistantAPI {
bool? favoriteOnly,
String? artistId,
String? albumId,
String? orderBy,
String? provider, // Filter to specific provider instance
}) async {
try {
final response = await _sendCommand(
@@ -473,9 +494,11 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (provider != null) 'provider': provider,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist': artistId,
if (albumId != null) 'album': albumId,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -496,6 +519,7 @@ class MusicAssistantAPI {
int? limit,
int? offset,
bool? favoriteOnly,
String? orderBy,
}) async {
try {
final response = await _sendCommand(
@@ -504,6 +528,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -555,6 +580,7 @@ class MusicAssistantAPI {
int? limit,
int? offset,
bool? favoriteOnly,
String? orderBy,
}) async {
try {
final response = await _sendCommand(
@@ -563,6 +589,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -1338,13 +1365,14 @@ class MusicAssistantAPI {
}
/// Get random albums
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
Future<List<Album>> getRandomAlbums({int limit = 10, String? provider}) async {
try {
final response = await _sendCommand(
'music/albums/library_items',
args: {
'limit': limit,
'order_by': 'random',
if (provider != null) 'provider': provider,
},
);
@@ -1460,6 +1488,7 @@ class MusicAssistantAPI {
int? offset,
String? search,
bool? favoriteOnly,
String? orderBy,
}) async {
try {
final response = await _sendCommand(
@@ -1469,6 +1498,7 @@ class MusicAssistantAPI {
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -1490,7 +1520,7 @@ class MusicAssistantAPI {
final response = await _sendCommand(
'music/playlist',
args: {
'provider': provider,
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
@@ -3269,10 +3299,22 @@ class MusicAssistantAPI {
_logger.log('⚠️ Could not load provider manifests: $e');
}
// Fetch provider instances (active providers)
// Fetch provider instances (active providers) and extract music providers
try {
await _sendCommand('providers');
_logger.log('✓ Provider instances loaded');
final response = await _sendCommand('providers');
final providers = response['result'] as List<dynamic>?;
if (providers != null) {
_musicProviders = providers
.where((p) => (p as Map<String, dynamic>)['type'] == 'music')
.map((p) => MusicProvider.fromJson(p as Map<String, dynamic>))
.where((p) => p.available) // Only include available providers
.where((p) => p.domain != 'music_assistant') // Exclude MA itself
.toList();
_logger.log('✓ Found ${_musicProviders.length} music providers:');
for (final p in _musicProviders) {
_logger.log(' - ${p.name}: instanceId="${p.instanceId}", domain="${p.domain}"');
}
}
} catch (e) {
_logger.log('⚠️ Could not load provider instances: $e');
}
+47
View File
@@ -172,6 +172,9 @@ class SettingsService {
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
// Music provider filter
static const String _keyDisabledMusicProviders = 'disabled_music_providers'; // JSON list of disabled provider instance IDs
static Future<String?> getServerUrl() async {
final prefs = await _getPrefs();
return prefs.getString(_keyServerUrl);
@@ -871,4 +874,48 @@ class SettingsService {
cache[podcastId] = url;
await setPodcastCoverCache(cache);
}
// ============ MUSIC PROVIDER FILTER ============
/// Get list of disabled music provider instance IDs
/// Returns empty list if none disabled (all providers enabled)
static Future<List<String>> getDisabledMusicProviders() async {
final prefs = await _getPrefs();
final json = prefs.getString(_keyDisabledMusicProviders);
if (json == null || json.isEmpty) return [];
try {
return (jsonDecode(json) as List<dynamic>).cast<String>();
} catch (_) {
return [];
}
}
/// Set list of disabled music provider instance IDs
static Future<void> setDisabledMusicProviders(List<String> disabledIds) async {
final prefs = await _getPrefs();
if (disabledIds.isEmpty) {
await prefs.remove(_keyDisabledMusicProviders);
} else {
await prefs.setString(_keyDisabledMusicProviders, jsonEncode(disabledIds));
}
}
/// Check if a specific provider is disabled
static Future<bool> isProviderDisabled(String instanceId) async {
final disabled = await getDisabledMusicProviders();
return disabled.contains(instanceId);
}
/// Toggle a provider's enabled/disabled state
static Future<void> toggleProviderEnabled(String instanceId, bool enabled) async {
final disabled = await getDisabledMusicProviders();
if (enabled) {
disabled.remove(instanceId);
} else {
if (!disabled.contains(instanceId)) {
disabled.add(instanceId);
}
}
await setDisabledMusicProviders(disabled);
}
}
+13 -9
View File
@@ -119,7 +119,8 @@ class SyncService with ChangeNotifier {
/// Sync library data from MA API in background
/// Updates database cache and notifies listeners when complete
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
/// If provider is specified, only fetch items from that provider (server-side filtering)
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false, String? provider}) async {
if (_isSyncing) {
_logger.log('🔄 Sync already in progress, skipping');
return;
@@ -149,16 +150,17 @@ class SyncService with ChangeNotifier {
notifyListeners();
try {
_logger.log('🔄 Starting background library sync...');
_logger.log('🔄 Starting background library sync (provider=$provider)...');
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums, provider: $provider');
// Fetch fresh data from MA API (in parallel for speed)
// If provider is specified, filter to only that provider's library items
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAlbums(limit: 1000, provider: provider),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, provider: provider),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
@@ -273,11 +275,12 @@ class SyncService with ChangeNotifier {
}
/// Force a fresh sync (for pull-to-refresh)
Future<void> forceSync(MusicAssistantAPI api) async {
await syncFromApi(api, force: true);
Future<void> forceSync(MusicAssistantAPI api, {String? provider}) async {
await syncFromApi(api, force: true, provider: provider);
}
/// Clear all cached data
/// Clear all cached data and reset sync state
/// This allows a fresh sync to start even if one was in progress
Future<void> clearCache() async {
if (!_db.isInitialized) return;
@@ -287,9 +290,10 @@ class SyncService with ChangeNotifier {
_cachedAudiobooks = [];
_cachedPlaylists = [];
_lastSyncTime = null;
_isSyncing = false; // Reset so a new sync can start
_status = SyncStatus.idle;
notifyListeners();
_logger.log('🗑️ Library cache cleared');
_logger.log('🗑️ Library cache cleared (sync state reset)');
}
/// Get albums (from cache or empty if not loaded)