Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0cac2174f | ||
|
|
056d1fb854 | ||
|
|
a4cc7115bf | ||
|
|
1f3188fa6c | ||
|
|
9527ba341e | ||
|
|
79e9e02f53 | ||
|
|
d14a4b99c6 | ||
|
|
7e72b08f44 | ||
|
|
801952482f | ||
|
|
bae1154bcf |
@@ -0,0 +1,336 @@
|
||||
# Per-Provider Sync Implementation
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### The Core Issue
|
||||
|
||||
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
|
||||
|
||||
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
|
||||
|
||||
### Previous Approach (Failed)
|
||||
|
||||
The initial implementation tried to filter client-side using `provider_mappings`:
|
||||
|
||||
```dart
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
|
||||
|
||||
### Race Condition Issue
|
||||
|
||||
Additionally, when rapidly toggling providers, a race condition occurred:
|
||||
1. User toggles Provider A off
|
||||
2. Sync starts for remaining providers
|
||||
3. User toggles Provider B off before sync completes
|
||||
4. First sync completes with wrong filter state
|
||||
5. UI shows incorrect results
|
||||
|
||||
## Solution: Per-Provider Sync with Source Tracking
|
||||
|
||||
### Concept
|
||||
|
||||
Instead of relying on `provider_mappings`, we:
|
||||
1. **Sync each provider separately** using the MA API's `provider` parameter
|
||||
2. **Tag each item** with which provider instance(s) returned it
|
||||
3. **Store source tracking** in the local database
|
||||
4. **Filter client-side** using the source tracking data (instant)
|
||||
5. **Background sync** keeps data fresh without blocking UI
|
||||
|
||||
### Why This Works
|
||||
|
||||
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema Update
|
||||
|
||||
**File:** `lib/database/database.dart`
|
||||
|
||||
Added `sourceProviders` column to `LibraryCache` table:
|
||||
|
||||
```dart
|
||||
class LibraryCache extends Table {
|
||||
// ... existing columns ...
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
}
|
||||
```
|
||||
|
||||
**Schema version:** 5 → 6
|
||||
|
||||
**Migration:**
|
||||
```dart
|
||||
if (from < 6) {
|
||||
await customStatement(
|
||||
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Service Updates
|
||||
|
||||
**File:** `lib/services/database_service.dart`
|
||||
|
||||
Added `sourceProvider` parameter to `cacheItem()`:
|
||||
|
||||
```dart
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider, // NEW: tracks which provider this item came from
|
||||
})
|
||||
```
|
||||
|
||||
Added new method to retrieve items with source tracking:
|
||||
|
||||
```dart
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
|
||||
```
|
||||
|
||||
Returns tuples of `(itemData, sourceProviderIds)`.
|
||||
|
||||
### 3. SyncService Rewrite
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
#### New Fields
|
||||
|
||||
```dart
|
||||
// Source provider tracking for client-side filtering
|
||||
Map<String, List<String>> _albumSourceProviders = {};
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
```
|
||||
|
||||
#### Per-Provider Sync Logic
|
||||
|
||||
```dart
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {
|
||||
bool force = false,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
// If specific providers are requested, sync each separately
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
for (final providerId in providerInstanceIds) {
|
||||
// Fetch from this specific provider
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
|
||||
// ... etc
|
||||
]);
|
||||
|
||||
// Track source provider for each item
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
|
||||
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
|
||||
_albumSourceProviders[album.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Client-Side Filtering Methods
|
||||
|
||||
```dart
|
||||
/// Filter albums by source provider (instant, no network)
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
if (sources == null || sources.isEmpty) return true; // No tracking = show
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Similar methods for artists, audiobooks, playlists
|
||||
```
|
||||
|
||||
### 4. Library Screen Updates
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
#### Hybrid Provider Toggle Handler
|
||||
|
||||
```dart
|
||||
void _handleProviderToggle(String providerId, bool enabled) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Instant UI update via global provider toggle
|
||||
maProvider.toggleProviderEnabled(providerId, enabled);
|
||||
setState(() {}); // Rebuild with client-side filtering
|
||||
|
||||
// Background sync after 1.5s debounce
|
||||
_providerFilterDebounce?.cancel();
|
||||
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
|
||||
if (mounted) {
|
||||
await maProvider.forceLibrarySync();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Updated Tab Builders
|
||||
|
||||
Artists tab now uses source tracking:
|
||||
|
||||
```dart
|
||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
|
||||
selector: (_, provider) => (
|
||||
provider.artists,
|
||||
provider.isLoading,
|
||||
provider.enabledProviderIds.toSet()
|
||||
),
|
||||
builder: (context, data, _) {
|
||||
final (allArtists, isLoading, enabledProviders) = data;
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
final syncService = SyncService.instance;
|
||||
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getArtistsFilteredByProviders(enabledProviders)
|
||||
: allArtists;
|
||||
|
||||
// ... rest of build
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Similar updates for albums, playlists, and audiobooks tabs.
|
||||
|
||||
### 5. Provider Instance Model
|
||||
|
||||
**File:** `lib/models/provider_instance.dart` (new file)
|
||||
|
||||
```dart
|
||||
class ProviderInstance {
|
||||
final String instanceId; // e.g., "spotify--abc123"
|
||||
final String domain; // e.g., "spotify"
|
||||
final String name; // e.g., "Spotify (John's Account)"
|
||||
final bool available;
|
||||
|
||||
/// Known music provider domains
|
||||
static const Set<String> musicProviderDomains = {
|
||||
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
|
||||
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
|
||||
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
|
||||
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
|
||||
};
|
||||
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
|
||||
│ - Updates enabledProviderIds │
|
||||
│ - Triggers setState() for instant UI update │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2. UI Rebuilds Immediately │
|
||||
│ - Selector detects enabledProviderIds change │
|
||||
│ - Calls SyncService.getArtistsFilteredByProviders() │
|
||||
│ - Filters using source tracking (instant, no network) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Background Sync (after 1.5s debounce) │
|
||||
│ - forceLibrarySync() called │
|
||||
│ - Syncs each enabled provider separately │
|
||||
│ - Updates source tracking in database │
|
||||
│ - UI refreshes with fresh data │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
|
||||
| `lib/database/database.g.dart` | Auto-generated from schema changes |
|
||||
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
|
||||
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
|
||||
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
|
||||
| `lib/models/provider_instance.dart` | New model for provider instances |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Fresh install: First sync should populate source tracking
|
||||
- [ ] Toggle single provider: UI updates instantly
|
||||
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
|
||||
- [ ] Two Spotify accounts: Toggling one only shows that account's items
|
||||
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
|
||||
- [ ] Legacy cache: Items without source tracking show regardless of filter
|
||||
- [ ] Pull to refresh: Refreshes source tracking data
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
|
||||
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
|
||||
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Incremental sync:** Only sync changed items instead of full library
|
||||
2. **Provider-specific cache age:** Different sync intervals per provider
|
||||
3. **Offline source tracking:** Persist source tracking independently of full cache
|
||||
|
||||
## Branch
|
||||
|
||||
All changes are on the `feature/per-provider-sync` branch.
|
||||
|
||||
```bash
|
||||
git checkout feature/per-provider-sync
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat: Add per-provider sync with source tracking for instant filtering
|
||||
|
||||
- Add sourceProviders column to database schema (v6) for tracking which
|
||||
provider instance each item came from
|
||||
- Update SyncService to sync each enabled provider separately and tag
|
||||
items with their source provider
|
||||
- Add client-side filtering methods that use source tracking to properly
|
||||
differentiate between multiple accounts of the same provider type
|
||||
- Update library UI to use instant client-side filtering with background
|
||||
sync for data freshness
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# Provider Filter Implementation Notes
|
||||
|
||||
## Overview
|
||||
|
||||
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
|
||||
|
||||
## The Problem
|
||||
|
||||
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
|
||||
|
||||
## What Works vs What Failed
|
||||
|
||||
### Works: Different Provider Types
|
||||
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
|
||||
|
||||
### Failed: Multiple Accounts of Same Provider
|
||||
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### The provider_mappings Problem
|
||||
|
||||
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
|
||||
|
||||
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
|
||||
```json
|
||||
{
|
||||
"provider_mappings": [
|
||||
{"provider_instance": "spotify--userA_id", "item_id": "..."},
|
||||
{"provider_instance": "spotify--userB_id", "item_id": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This happens because:
|
||||
1. MA's internal database stores that the artist exists
|
||||
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
|
||||
3. MA doesn't track WHICH account originally added the item to the library
|
||||
|
||||
### MA Web UI Behavior
|
||||
|
||||
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
|
||||
|
||||
## API Investigation
|
||||
|
||||
### Server-Side Filtering Parameter
|
||||
|
||||
The MA server's `library_items` method accepts a `provider` parameter:
|
||||
- Parameter name: `provider` (string or list of strings)
|
||||
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
|
||||
- Expected behavior: Filter to only return items from the specified provider's library
|
||||
|
||||
### What We Tried
|
||||
|
||||
1. **Client-side filtering based on provider_mappings**
|
||||
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
|
||||
|
||||
2. **Server-side filtering with `provider` parameter**
|
||||
- Result: Partially implemented but behavior was inconsistent
|
||||
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
|
||||
|
||||
3. **Using `provider_instance_id_or_domain` parameter**
|
||||
- Result: This parameter is for getting specific items by ID, not for filtering library_items
|
||||
|
||||
## Code Locations
|
||||
|
||||
### Files Modified (in stash)
|
||||
|
||||
The implementation attempt is preserved in git stash:
|
||||
```bash
|
||||
git stash list # Look for "provider-filter-and-dropdown-work"
|
||||
git stash show -p stash@{0} # View the changes
|
||||
```
|
||||
|
||||
Key files that were modified:
|
||||
|
||||
1. **lib/providers/music_assistant_provider.dart**
|
||||
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
|
||||
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
|
||||
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
|
||||
- `loadDisabledProviders()`: Load from local settings
|
||||
- `filterByProvider()`: Client-side filtering (enhanced version)
|
||||
- `isItemAllowedByProviderFilter()`: Check if item should be visible
|
||||
|
||||
2. **lib/services/music_assistant_api.dart**
|
||||
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
|
||||
- Added `MusicProvider` model parsing from `/providers` endpoint
|
||||
- `musicProviders` getter: List of available music providers
|
||||
|
||||
3. **lib/services/sync_service.dart**
|
||||
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
|
||||
- Pass provider filter to API calls during sync
|
||||
|
||||
4. **lib/services/settings_service.dart**
|
||||
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
|
||||
- `setDisabledMusicProviders()`: Save disabled providers
|
||||
- `toggleProviderEnabled()`: Toggle and persist
|
||||
|
||||
5. **lib/screens/settings_screen.dart**
|
||||
- UI for toggling individual providers on/off
|
||||
- Display provider name and instance ID
|
||||
|
||||
6. **lib/screens/new_library_screen.dart**
|
||||
- Integration with provider filtering
|
||||
|
||||
7. **lib/models/music_provider.dart** (new file)
|
||||
- Model for MA provider instances
|
||||
- Fields: `instanceId`, `domain`, `name`, `available`
|
||||
|
||||
## Race Condition Issue
|
||||
|
||||
A race condition was identified where:
|
||||
1. Initial app sync starts WITHOUT provider filter
|
||||
2. User toggles provider filter
|
||||
3. `clearCache()` is called
|
||||
4. New sync with provider filter tries to start
|
||||
5. "Sync already in progress" - new sync is skipped
|
||||
6. Result: Old unfiltered data remains
|
||||
|
||||
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
|
||||
|
||||
## Potential Solutions to Explore
|
||||
|
||||
### 1. Investigate MA Server-Side Filtering
|
||||
|
||||
The MA server should support filtering by provider. Need to verify:
|
||||
- Does the `provider` parameter work for filtering between same-type providers?
|
||||
- Is there a different parameter or API endpoint?
|
||||
- Check MA source code: https://github.com/music-assistant/server
|
||||
|
||||
### 2. Library Sync Per Provider
|
||||
|
||||
Instead of syncing all items and filtering, sync each provider's library separately:
|
||||
```dart
|
||||
for (final provider in enabledProviders) {
|
||||
final items = await api.getAlbums(provider: provider.instanceId);
|
||||
// Store with provider association
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Track Item Origin
|
||||
|
||||
Store which provider originally added each item:
|
||||
- Modify database schema to track `source_provider`
|
||||
- When syncing, record which provider the item came from
|
||||
- Filter based on source, not playability
|
||||
|
||||
### 4. Use MA's Internal Library State
|
||||
|
||||
MA might have internal state about which items are in which provider's library. Investigate:
|
||||
- `/music/library/items` endpoint
|
||||
- Provider-specific library endpoints
|
||||
- MA's database schema for library associations
|
||||
|
||||
### 5. Check MA User Settings
|
||||
|
||||
The MA web UI's provider filter might be stored in user settings:
|
||||
```dart
|
||||
final userInfo = await api.getCurrentUserInfo();
|
||||
// Check for provider_filter or similar field
|
||||
```
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Log Messages to Watch
|
||||
|
||||
```
|
||||
🔌 Selected provider for filter: spotify--xxxxx
|
||||
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
|
||||
🔍 API getArtists returned XXX items
|
||||
🔌 filterByProvider: XXX → YYY (disabled=[...])
|
||||
```
|
||||
|
||||
### Test Scenario
|
||||
|
||||
1. Configure two Spotify accounts in MA
|
||||
2. Add different artists to each account's library
|
||||
3. In Ensemble, select only one Spotify account
|
||||
4. Verify library shows ONLY that account's items
|
||||
5. Switch to other account, verify different items
|
||||
|
||||
### Expected vs Actual
|
||||
|
||||
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
|
||||
- **Actual**: Artist appears regardless of which account is selected
|
||||
|
||||
## MA API Reference
|
||||
|
||||
### Get Providers
|
||||
```
|
||||
Command: providers
|
||||
Returns: List of provider instances with type, domain, instanceId, name, available
|
||||
```
|
||||
|
||||
### Library Items with Filter
|
||||
```
|
||||
Command: music/artists/library_items
|
||||
Args: {
|
||||
limit: int,
|
||||
offset: int,
|
||||
provider: string | list<string>, // Filter to specific provider(s)
|
||||
album_artists_only: bool,
|
||||
order_by: string
|
||||
}
|
||||
```
|
||||
|
||||
### Relevant MA Source Files
|
||||
|
||||
- `music_assistant/server/controllers/music.py` - Library item retrieval
|
||||
- `music_assistant/server/models/provider.py` - Provider model
|
||||
- `music_assistant/server/providers/` - Individual provider implementations
|
||||
|
||||
## Conclusion
|
||||
|
||||
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
|
||||
|
||||
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Audiobook Provider Filtering Fix
|
||||
|
||||
## Date: January 17, 2026 (Continuation)
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
|
||||
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
|
||||
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
|
||||
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
After implementing capability-based provider filtering for the music library, two issues remained:
|
||||
|
||||
### Issue 1: Audiobooks Not Filtering During Sync
|
||||
|
||||
The bug report showed:
|
||||
```
|
||||
📊 Source tracking: 252 albums, 87 artists have provider info
|
||||
📚 Total audiobooks from browse: 186
|
||||
```
|
||||
|
||||
- Albums and artists had provider source tracking
|
||||
- Audiobooks had NO provider source tracking (not listed)
|
||||
- All 186 audiobooks were returned regardless of provider selection
|
||||
|
||||
### Issue 2: UI Not Updating Instantly
|
||||
|
||||
When toggling providers in the library menu:
|
||||
- Music tabs weren't updating immediately
|
||||
- Audiobooks required manual pull-to-refresh
|
||||
- Changes only appeared after ~600ms (debounce timer)
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Cause 1: Browse API Ignoring Provider Filter
|
||||
|
||||
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
|
||||
|
||||
```dart
|
||||
// BEFORE: providerInstanceIds was ignored when using browse API
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Cause 2: `notifyListeners()` Called Too Late
|
||||
|
||||
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
|
||||
|
||||
```dart
|
||||
// BEFORE: notifyListeners only called after 600ms debounce
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
forceLibrarySync();
|
||||
notifyListeners(); // Too late! UI should update immediately
|
||||
});
|
||||
```
|
||||
|
||||
### Cause 3: Audiobook Tabs Not Listening for Changes
|
||||
|
||||
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
|
||||
|
||||
```dart
|
||||
// BEFORE: context.read doesn't listen for changes
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
// ... widget doesn't rebuild when enabledProviders changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solutions
|
||||
|
||||
### Fix 1: Browse API Provider Filtering
|
||||
|
||||
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
|
||||
|
||||
Added logic to filter library paths by provider ID before browsing:
|
||||
|
||||
```dart
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
|
||||
|
||||
// If specific providers are requested, filter libraries to only matching providers
|
||||
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
|
||||
var librariesToBrowse = enabledLibraries;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
librariesToBrowse = enabledLibraries.where((libPath) {
|
||||
// Extract provider ID from library path (everything before "://")
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd == -1) return false;
|
||||
final providerId = libPath.substring(0, providerEnd);
|
||||
return providerInstanceIds.contains(providerId);
|
||||
}).toList();
|
||||
|
||||
// If no libraries match the requested providers, return empty
|
||||
if (librariesToBrowse.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2: Immediate `notifyListeners()` Call
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
|
||||
|
||||
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
// Reload the enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
_enabledProviderIds = savedEnabled ?? [];
|
||||
|
||||
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
|
||||
// This enables instant UI updates using cached data with source tracking
|
||||
notifyListeners();
|
||||
|
||||
// Cancel any pending debounce timer
|
||||
_providerFilterDebounceTimer?.cancel();
|
||||
|
||||
// Start debounced sync - waits 600ms for user to finish toggling
|
||||
// This refreshes the cache with new data from enabled providers
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
_logger.log('🔄 Debounce complete, starting library sync...');
|
||||
forceLibrarySync();
|
||||
});
|
||||
```
|
||||
|
||||
### Fix 3: Audiobook Tabs Use Selector
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
// Use Selector to rebuild when enabledProviderIds changes
|
||||
return Selector<MusicAssistantProvider, Set<String>>(
|
||||
selector: (_, provider) => provider.enabledProviderIds.toSet(),
|
||||
builder: (context, enabledProviders, _) {
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Client-side filtering using SyncService source tracking for instant updates
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// ... rest of widget
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 4: Enhanced Sync Logging
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
Added audiobook counts to logging:
|
||||
|
||||
```dart
|
||||
// Per-provider log (line 237)
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
|
||||
// Source tracking summary (line 323)
|
||||
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow After Fixes
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
│ │
|
||||
│ 1. toggleProviderEnabled() called │
|
||||
│ 2. _enabledProviderIds updated │
|
||||
│ 3. notifyListeners() called IMMEDIATELY │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Selectors Detect Change │
|
||||
│ │
|
||||
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
|
||||
│ Audiobook tabs: Selector<..., Set<String>> │
|
||||
│ │
|
||||
│ → All tabs rebuild immediately │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client-Side Filtering (Instant) │
|
||||
│ │
|
||||
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
|
||||
│ │
|
||||
│ → Uses EXISTING cache with source tracking │
|
||||
│ → Instant UI update, no network required │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Background Sync (After 600ms Debounce) │
|
||||
│ │
|
||||
│ - Refreshes cache with new data from enabled providers │
|
||||
│ - Updates source tracking │
|
||||
│ - Only needed when ENABLING providers (to fetch new data) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
|
||||
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
|
||||
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
|
||||
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior After Fixes
|
||||
|
||||
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
|
||||
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
|
||||
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
|
||||
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
|
||||
- [ ] Toggle between different Spotify accounts, verify correct filtering
|
||||
- [ ] Sync with ABS library filtering enabled
|
||||
- [ ] Verify audiobooks appear in source tracking log
|
||||
- [ ] Toggle ABS provider off in library menu
|
||||
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
|
||||
- [ ] Toggle ABS provider back on
|
||||
- [ ] Verify audiobooks reappear after sync completes
|
||||
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
|
||||
|
||||
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
|
||||
|
||||
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### 1. Podcast Provider Counts Showing Incorrectly
|
||||
|
||||
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
|
||||
|
||||
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
|
||||
|
||||
**Solution**:
|
||||
- Added `inLibrary` field to `ProviderMapping` model
|
||||
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
|
||||
- Updated podcast filtering in library screen to check `inLibrary` field
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/media_item.dart` - Added `inLibrary` field
|
||||
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
|
||||
- `lib/screens/new_library_screen.dart` - Updated filtering logic
|
||||
|
||||
### 2. iTunes Podcasts Not Appearing in Library
|
||||
|
||||
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
|
||||
|
||||
**Investigation**:
|
||||
- Added `itunes_podcasts` to provider capabilities and music provider domains
|
||||
- Added synthetic provider instance creation for search-only providers
|
||||
- Added debug logging to understand podcast loading
|
||||
|
||||
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
|
||||
|
||||
### 3. Episode Loading Failing for Non-Library Podcasts
|
||||
|
||||
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
|
||||
|
||||
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
|
||||
|
||||
### 4. Search Filtering Rework
|
||||
|
||||
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
|
||||
|
||||
**Solution**:
|
||||
- Removed `filterSearchResults()` calls from search methods
|
||||
- Added client-side `_filterByEnabledProviders()` helper
|
||||
- Applied filtering only when `_libraryOnly` toggle is ON
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
|
||||
- `lib/screens/search_screen.dart` - Added client-side filtering
|
||||
|
||||
### 5. Library Buttons Not Updating Immediately
|
||||
|
||||
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
|
||||
|
||||
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart`
|
||||
- `lib/screens/album_details_screen.dart`
|
||||
- `lib/screens/artist_details_screen.dart`
|
||||
|
||||
## Code Changes
|
||||
|
||||
### ProviderMapping.inLibrary Field
|
||||
|
||||
```dart
|
||||
// lib/models/media_item.dart
|
||||
class ProviderMapping {
|
||||
final String itemId;
|
||||
final String providerDomain;
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
final bool inLibrary; // NEW: indicates ownership
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderMapping(
|
||||
// ... other fields ...
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Podcast Provider Counting
|
||||
|
||||
```dart
|
||||
// lib/providers/music_assistant_provider.dart
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in _podcasts) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Update Pattern
|
||||
|
||||
```dart
|
||||
// Example from podcast_detail_screen.dart
|
||||
Future<void> _toggleLibrary() async {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(...).catchError((e) {
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Search Filtering
|
||||
|
||||
```dart
|
||||
// lib/screens/search_screen.dart
|
||||
Map<String, List<MediaItem>> _filterByEnabledProviders(
|
||||
Map<String, List<MediaItem>> results,
|
||||
Set<String> enabledProviders,
|
||||
) {
|
||||
if (enabledProviders.isEmpty) return results;
|
||||
|
||||
bool isItemAllowed(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings == null || mappings.isEmpty) return true;
|
||||
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowed).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// Applied when setting results:
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Capabilities Update
|
||||
|
||||
```dart
|
||||
// lib/models/provider_instance.dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'}, // NEW
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
// ... other providers
|
||||
};
|
||||
```
|
||||
|
||||
## Summary of All Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
|
||||
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
|
||||
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
|
||||
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
|
||||
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
|
||||
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
|
||||
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
|
||||
|
||||
## Key Learnings
|
||||
|
||||
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
|
||||
|
||||
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
|
||||
|
||||
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
|
||||
|
||||
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
|
||||
@@ -0,0 +1,263 @@
|
||||
# Provider Filtering Improvements
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Provider Capability Mapping
|
||||
|
||||
**File:** `lib/models/provider_instance.dart`
|
||||
|
||||
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
|
||||
|
||||
```dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Self-hosted media servers (can have music and audiobooks)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Audiobook-only providers
|
||||
'audiobookshelf': {'audiobooks'},
|
||||
|
||||
// Radio providers
|
||||
'tunein': {'radio'},
|
||||
'radiobrowser': {'radio'},
|
||||
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
```
|
||||
|
||||
**Added helper methods:**
|
||||
```dart
|
||||
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
|
||||
bool supportsContentType(String category) => supportedContentTypes.contains(category);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Radio providers never show in Artists tab (by design, not by count)
|
||||
- Works immediately, even with empty libraries
|
||||
- Disabled providers still show in menu (if they support the content type)
|
||||
- Clean separation of concerns
|
||||
|
||||
---
|
||||
|
||||
### 2. Updated Provider Filtering in MusicAssistantProvider
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart`
|
||||
|
||||
Updated `getRelevantProvidersForCategory()` to filter by capability:
|
||||
|
||||
```dart
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
// ... get counts ...
|
||||
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
for (final provider in _availableMusicProviders) {
|
||||
// Only include providers that support this content type
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
result.add((provider, count));
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Simplified Library Menu Provider Filter
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Simplified the options menu overlay to show all providers that support the current category:
|
||||
|
||||
**Before (clunky):**
|
||||
```dart
|
||||
if (widget.relevantProviders.where((p) {
|
||||
final hasItems = p.$2 > 0;
|
||||
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
|
||||
!widget.enabledProviderIds.contains(p.$1.instanceId);
|
||||
return hasItems || isDisabledByUser;
|
||||
}).length > 1) ...[
|
||||
```
|
||||
|
||||
**After (clean):**
|
||||
```dart
|
||||
// relevantProviders is pre-filtered by capability
|
||||
if (widget.relevantProviders.length > 1) ...[
|
||||
// Show all providers that support this category
|
||||
...widget.relevantProviders.map((providerData) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Removed Provider Toggles from Settings Screen
|
||||
|
||||
**File:** `lib/screens/settings_screen.dart`
|
||||
|
||||
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
|
||||
|
||||
- Removed `_providerEnabled` state variable
|
||||
- Removed provider loading code from `_loadSettings()`
|
||||
- Removed the entire Music Providers UI section (~100 lines)
|
||||
|
||||
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
|
||||
|
||||
---
|
||||
|
||||
### 5. Fixed Audiobooks Library Provider Filtering
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
|
||||
|
||||
**Fixed `_buildBooksAuthorsTab()`:**
|
||||
```dart
|
||||
final syncService = SyncService.instance;
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// Filter by favorites if enabled
|
||||
if (_showFavoritesOnly) {
|
||||
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
// Group filtered audiobooks by author (dynamic, not cached)
|
||||
final groupedByAuthor = <String, List<Audiobook>>{};
|
||||
for (final book in audiobooks) {
|
||||
final authorName = book.authorsString;
|
||||
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed `_buildAllBooksTab()`:**
|
||||
- Same provider filtering pattern
|
||||
- Dynamic sorting after filtering
|
||||
- Updated PageStorageKey to include provider count for proper rebuild
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Provider Filter Menu Disappearing
|
||||
|
||||
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
|
||||
|
||||
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
|
||||
|
||||
### Audiobooks Not Filtering by Provider
|
||||
|
||||
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
|
||||
|
||||
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
|
||||
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
|
||||
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Provider Capability Map │
|
||||
│ (ProviderInstance.providerCapabilities) │
|
||||
│ │
|
||||
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
|
||||
│ audiobookshelf → {audiobooks} │
|
||||
│ tunein → {radio} │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ getRelevantProvidersForCategory(category) │
|
||||
│ │
|
||||
│ - Filters _availableMusicProviders by capability │
|
||||
│ - Returns only providers that support the category │
|
||||
│ - Includes item counts for display │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Library Menu (Options) │
|
||||
│ │
|
||||
│ - Shows providers section if > 1 providers support category │
|
||||
│ - All relevant providers shown (can toggle on/off) │
|
||||
│ - Instant UI update via setState() │
|
||||
│ - Background sync after 1.5s debounce │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tab Build Methods │
|
||||
│ │
|
||||
│ - Get enabledProviderIds from MusicAssistantProvider │
|
||||
│ - Filter data using SyncService source tracking │
|
||||
│ - Display filtered results │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
|
||||
|
||||
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Music tabs filter by provider correctly
|
||||
- [x] Provider menu shows only relevant providers per category
|
||||
- [x] Radio providers don't appear in Artists/Albums tabs
|
||||
- [x] Spotify appears in Audiobooks tab (has audiobooks)
|
||||
- [x] Audiobooks Authors tab filters by provider
|
||||
- [x] Audiobooks All Books tab filters by provider
|
||||
- [ ] Audiobooks Series tab filters by provider (not implemented)
|
||||
- [x] Disabled providers can be re-enabled from menu
|
||||
- [x] Settings screen no longer has provider toggles
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
|
||||
Binary file not shown.
@@ -78,6 +78,10 @@ class LibraryCache extends Table {
|
||||
/// Whether this item was deleted on the server
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
@@ -191,7 +195,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 5;
|
||||
int get schemaVersion => 6;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -226,6 +230,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
|
||||
}
|
||||
// Migration from v5 to v6: Add source_providers column for client-side filtering
|
||||
if (from < 6) {
|
||||
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -368,20 +376,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
// ============================================
|
||||
|
||||
/// Cache a library item
|
||||
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
|
||||
Future<void> cacheItem({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required String data,
|
||||
String? sourceProvider,
|
||||
}) async {
|
||||
final cacheKey = '${itemType}_$itemId';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
// If sourceProvider is specified, we need to merge with existing providers
|
||||
if (sourceProvider != null) {
|
||||
final existing = await getCachedItem(itemType, itemId);
|
||||
List<String> providers = [];
|
||||
if (existing != null) {
|
||||
try {
|
||||
final existingProviders = existing.sourceProviders;
|
||||
if (existingProviders.isNotEmpty && existingProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
(await Future.value(existingProviders))
|
||||
.replaceAll('[', '')
|
||||
.replaceAll(']', '')
|
||||
.replaceAll('"', '')
|
||||
.split(',')
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.map((s) => s.trim()),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!providers.contains(sourceProvider)) {
|
||||
providers.add(sourceProvider);
|
||||
}
|
||||
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
));
|
||||
} else {
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached items by type
|
||||
|
||||
@@ -856,9 +856,24 @@ class $LibraryCacheTable extends LibraryCache
|
||||
defaultConstraints:
|
||||
GeneratedColumn.constraintIsAlways('CHECK ("is_deleted" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
static const VerificationMeta _sourceProvidersMeta =
|
||||
const VerificationMeta('sourceProviders');
|
||||
@override
|
||||
List<GeneratedColumn> get $columns =>
|
||||
[cacheKey, itemType, itemId, data, lastSynced, isDeleted];
|
||||
late final GeneratedColumn<String> sourceProviders = GeneratedColumn<String>(
|
||||
'source_providers', aliasedName, false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant('[]'));
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
cacheKey,
|
||||
itemType,
|
||||
itemId,
|
||||
data,
|
||||
lastSynced,
|
||||
isDeleted,
|
||||
sourceProviders
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
@@ -905,6 +920,12 @@ class $LibraryCacheTable extends LibraryCache
|
||||
context.handle(_isDeletedMeta,
|
||||
isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta));
|
||||
}
|
||||
if (data.containsKey('source_providers')) {
|
||||
context.handle(
|
||||
_sourceProvidersMeta,
|
||||
sourceProviders.isAcceptableOrUnknown(
|
||||
data['source_providers']!, _sourceProvidersMeta));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -926,6 +947,8 @@ class $LibraryCacheTable extends LibraryCache
|
||||
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_synced'])!,
|
||||
isDeleted: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.bool, data['${effectivePrefix}is_deleted'])!,
|
||||
sourceProviders: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string, data['${effectivePrefix}source_providers'])!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -954,13 +977,18 @@ class LibraryCacheData extends DataClass
|
||||
|
||||
/// Whether this item was deleted on the server
|
||||
final bool isDeleted;
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
final String sourceProviders;
|
||||
const LibraryCacheData(
|
||||
{required this.cacheKey,
|
||||
required this.itemType,
|
||||
required this.itemId,
|
||||
required this.data,
|
||||
required this.lastSynced,
|
||||
required this.isDeleted});
|
||||
required this.isDeleted,
|
||||
required this.sourceProviders});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
@@ -970,6 +998,7 @@ class LibraryCacheData extends DataClass
|
||||
map['data'] = Variable<String>(data);
|
||||
map['last_synced'] = Variable<DateTime>(lastSynced);
|
||||
map['is_deleted'] = Variable<bool>(isDeleted);
|
||||
map['source_providers'] = Variable<String>(sourceProviders);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -981,6 +1010,7 @@ class LibraryCacheData extends DataClass
|
||||
data: Value(data),
|
||||
lastSynced: Value(lastSynced),
|
||||
isDeleted: Value(isDeleted),
|
||||
sourceProviders: Value(sourceProviders),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -994,6 +1024,7 @@ class LibraryCacheData extends DataClass
|
||||
data: serializer.fromJson<String>(json['data']),
|
||||
lastSynced: serializer.fromJson<DateTime>(json['lastSynced']),
|
||||
isDeleted: serializer.fromJson<bool>(json['isDeleted']),
|
||||
sourceProviders: serializer.fromJson<String>(json['sourceProviders']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1006,6 +1037,7 @@ class LibraryCacheData extends DataClass
|
||||
'data': serializer.toJson<String>(data),
|
||||
'lastSynced': serializer.toJson<DateTime>(lastSynced),
|
||||
'isDeleted': serializer.toJson<bool>(isDeleted),
|
||||
'sourceProviders': serializer.toJson<String>(sourceProviders),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1015,7 +1047,8 @@ class LibraryCacheData extends DataClass
|
||||
String? itemId,
|
||||
String? data,
|
||||
DateTime? lastSynced,
|
||||
bool? isDeleted}) =>
|
||||
bool? isDeleted,
|
||||
String? sourceProviders}) =>
|
||||
LibraryCacheData(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
itemType: itemType ?? this.itemType,
|
||||
@@ -1023,6 +1056,7 @@ class LibraryCacheData extends DataClass
|
||||
data: data ?? this.data,
|
||||
lastSynced: lastSynced ?? this.lastSynced,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
sourceProviders: sourceProviders ?? this.sourceProviders,
|
||||
);
|
||||
LibraryCacheData copyWithCompanion(LibraryCacheCompanion data) {
|
||||
return LibraryCacheData(
|
||||
@@ -1033,6 +1067,9 @@ class LibraryCacheData extends DataClass
|
||||
lastSynced:
|
||||
data.lastSynced.present ? data.lastSynced.value : this.lastSynced,
|
||||
isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted,
|
||||
sourceProviders: data.sourceProviders.present
|
||||
? data.sourceProviders.value
|
||||
: this.sourceProviders,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1044,14 +1081,15 @@ class LibraryCacheData extends DataClass
|
||||
..write('itemId: $itemId, ')
|
||||
..write('data: $data, ')
|
||||
..write('lastSynced: $lastSynced, ')
|
||||
..write('isDeleted: $isDeleted')
|
||||
..write('isDeleted: $isDeleted, ')
|
||||
..write('sourceProviders: $sourceProviders')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(cacheKey, itemType, itemId, data, lastSynced, isDeleted);
|
||||
int get hashCode => Object.hash(
|
||||
cacheKey, itemType, itemId, data, lastSynced, isDeleted, sourceProviders);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
@@ -1061,7 +1099,8 @@ class LibraryCacheData extends DataClass
|
||||
other.itemId == this.itemId &&
|
||||
other.data == this.data &&
|
||||
other.lastSynced == this.lastSynced &&
|
||||
other.isDeleted == this.isDeleted);
|
||||
other.isDeleted == this.isDeleted &&
|
||||
other.sourceProviders == this.sourceProviders);
|
||||
}
|
||||
|
||||
class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
@@ -1071,6 +1110,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
final Value<String> data;
|
||||
final Value<DateTime> lastSynced;
|
||||
final Value<bool> isDeleted;
|
||||
final Value<String> sourceProviders;
|
||||
final Value<int> rowid;
|
||||
const LibraryCacheCompanion({
|
||||
this.cacheKey = const Value.absent(),
|
||||
@@ -1079,6 +1119,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
this.data = const Value.absent(),
|
||||
this.lastSynced = const Value.absent(),
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.sourceProviders = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
LibraryCacheCompanion.insert({
|
||||
@@ -1088,6 +1129,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.sourceProviders = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : cacheKey = Value(cacheKey),
|
||||
itemType = Value(itemType),
|
||||
@@ -1101,6 +1143,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
Expression<String>? data,
|
||||
Expression<DateTime>? lastSynced,
|
||||
Expression<bool>? isDeleted,
|
||||
Expression<String>? sourceProviders,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
@@ -1110,6 +1153,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
if (data != null) 'data': data,
|
||||
if (lastSynced != null) 'last_synced': lastSynced,
|
||||
if (isDeleted != null) 'is_deleted': isDeleted,
|
||||
if (sourceProviders != null) 'source_providers': sourceProviders,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
@@ -1121,6 +1165,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
Value<String>? data,
|
||||
Value<DateTime>? lastSynced,
|
||||
Value<bool>? isDeleted,
|
||||
Value<String>? sourceProviders,
|
||||
Value<int>? rowid}) {
|
||||
return LibraryCacheCompanion(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
@@ -1129,6 +1174,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
data: data ?? this.data,
|
||||
lastSynced: lastSynced ?? this.lastSynced,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
sourceProviders: sourceProviders ?? this.sourceProviders,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
@@ -1154,6 +1200,9 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
if (isDeleted.present) {
|
||||
map['is_deleted'] = Variable<bool>(isDeleted.value);
|
||||
}
|
||||
if (sourceProviders.present) {
|
||||
map['source_providers'] = Variable<String>(sourceProviders.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
@@ -1169,6 +1218,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
..write('data: $data, ')
|
||||
..write('lastSynced: $lastSynced, ')
|
||||
..write('isDeleted: $isDeleted, ')
|
||||
..write('sourceProviders: $sourceProviders, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
@@ -3507,6 +3557,7 @@ typedef $$LibraryCacheTableCreateCompanionBuilder = LibraryCacheCompanion
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
Value<bool> isDeleted,
|
||||
Value<String> sourceProviders,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
|
||||
@@ -3517,6 +3568,7 @@ typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
|
||||
Value<String> data,
|
||||
Value<DateTime> lastSynced,
|
||||
Value<bool> isDeleted,
|
||||
Value<String> sourceProviders,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
@@ -3546,6 +3598,10 @@ class $$LibraryCacheTableFilterComposer
|
||||
|
||||
ColumnFilters<bool> get isDeleted => $composableBuilder(
|
||||
column: $table.isDeleted, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders,
|
||||
builder: (column) => ColumnFilters(column));
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableOrderingComposer
|
||||
@@ -3574,6 +3630,10 @@ class $$LibraryCacheTableOrderingComposer
|
||||
|
||||
ColumnOrderings<bool> get isDeleted => $composableBuilder(
|
||||
column: $table.isDeleted, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders,
|
||||
builder: (column) => ColumnOrderings(column));
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableAnnotationComposer
|
||||
@@ -3602,6 +3662,9 @@ class $$LibraryCacheTableAnnotationComposer
|
||||
|
||||
GeneratedColumn<bool> get isDeleted =>
|
||||
$composableBuilder(column: $table.isDeleted, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
@@ -3636,6 +3699,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
Value<String> data = const Value.absent(),
|
||||
Value<DateTime> lastSynced = const Value.absent(),
|
||||
Value<bool> isDeleted = const Value.absent(),
|
||||
Value<String> sourceProviders = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
LibraryCacheCompanion(
|
||||
@@ -3645,6 +3709,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
data: data,
|
||||
lastSynced: lastSynced,
|
||||
isDeleted: isDeleted,
|
||||
sourceProviders: sourceProviders,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback: ({
|
||||
@@ -3654,6 +3719,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
Value<bool> isDeleted = const Value.absent(),
|
||||
Value<String> sourceProviders = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
LibraryCacheCompanion.insert(
|
||||
@@ -3663,6 +3729,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
data: data,
|
||||
lastSynced: lastSynced,
|
||||
isDeleted: isDeleted,
|
||||
sourceProviders: sourceProviders,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
|
||||
@@ -1140,6 +1140,10 @@
|
||||
"audiobookLibraries": "Audiobook Libraries",
|
||||
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
|
||||
"unknownLibrary": "Unknown Library",
|
||||
"musicProviders": "Music Providers",
|
||||
"musicProvidersDescription": "Choose which accounts to show in your library",
|
||||
"libraryRefreshing": "Refreshing library...",
|
||||
"cannotDisableLastProvider": "Cannot disable the last provider",
|
||||
"noSeriesFound": "No Series Found",
|
||||
"ensembleBugReport": "Ensemble Bug Report",
|
||||
"byOwner": "By {owner}",
|
||||
@@ -1431,5 +1435,163 @@
|
||||
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
|
||||
"@longPressPodcastHint": {
|
||||
"description": "Hint for favoriting podcasts"
|
||||
},
|
||||
|
||||
"failedToPlayRadioStation": "Failed to play radio station: {error}",
|
||||
"@failedToPlayRadioStation": {
|
||||
"description": "Error when playing radio station fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"itemAlreadyInLibrary": "Item is already in library",
|
||||
"@itemAlreadyInLibrary": {
|
||||
"description": "Message when trying to add item already in library"
|
||||
},
|
||||
|
||||
"failedToAddToLibrary": "Failed to add to library",
|
||||
"@failedToAddToLibrary": {
|
||||
"description": "Error when adding to library fails"
|
||||
},
|
||||
|
||||
"cannotFindLibraryId": "Cannot find library ID for removal",
|
||||
"@cannotFindLibraryId": {
|
||||
"description": "Error when library ID not found for removal"
|
||||
},
|
||||
|
||||
"failedToRemoveFromLibrary": "Failed to remove from library",
|
||||
"@failedToRemoveFromLibrary": {
|
||||
"description": "Error when removing from library fails"
|
||||
},
|
||||
|
||||
"failedToPlayAlbum": "Failed to play album: {error}",
|
||||
"@failedToPlayAlbum": {
|
||||
"description": "Error when playing album fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"playingOnPlayer": "Playing {name} on {playerName}",
|
||||
"@playingOnPlayer": {
|
||||
"description": "Status when playing item on specific player",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayPlaylist": "Failed to play playlist: {error}",
|
||||
"@failedToPlayPlaylist": {
|
||||
"description": "Error when playing playlist fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
|
||||
"@failedToPlayAudiobook": {
|
||||
"description": "Error when playing audiobook fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noOtherPlayersAvailable": "No other players available",
|
||||
"@noOtherPlayersAvailable": {
|
||||
"description": "Message when no other players available for transfer"
|
||||
},
|
||||
|
||||
"transferQueue": "Transfer queue",
|
||||
"@transferQueue": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"clearQueue": "Clear queue",
|
||||
"@clearQueue": {
|
||||
"description": "Tooltip for clear queue button"
|
||||
},
|
||||
|
||||
"syncingPlayer": "Syncing {playerName}...",
|
||||
"@syncingPlayer": {
|
||||
"description": "Status when syncing player",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"switchPlayer": "Switch Player",
|
||||
"@switchPlayer": {
|
||||
"description": "Label for switch player action"
|
||||
},
|
||||
|
||||
"logsCopiedToClipboard": "Logs copied to clipboard",
|
||||
"@logsCopiedToClipboard": {
|
||||
"description": "Confirmation when logs copied"
|
||||
},
|
||||
|
||||
"auto": "Auto",
|
||||
"@auto": {
|
||||
"description": "Auto option for language selection"
|
||||
},
|
||||
|
||||
"transferQueueTo": "Transfer Queue To",
|
||||
"@transferQueueTo": {
|
||||
"description": "Title for transfer queue dialog"
|
||||
},
|
||||
|
||||
"queueTransferredTo": "Queue transferred to {playerName}",
|
||||
"@queueTransferredTo": {
|
||||
"description": "Success message when queue transferred",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToTransferQueue": "Failed to transfer queue: {error}",
|
||||
"@failedToTransferQueue": {
|
||||
"description": "Error when queue transfer fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToLoadQueue": "Failed to load queue: {error}",
|
||||
"@failedToLoadQueue": {
|
||||
"description": "Error when queue loading fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"transferTo": "Transfer to...",
|
||||
"@transferTo": {
|
||||
"description": "Label for transfer destination selection"
|
||||
},
|
||||
|
||||
"transferQueueToPlayer": "Transfer queue to another player",
|
||||
"@transferQueueToPlayer": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1563,6 +1563,30 @@ abstract class S {
|
||||
/// **'Unknown Library'**
|
||||
String get unknownLibrary;
|
||||
|
||||
/// No description provided for @musicProviders.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Music Providers'**
|
||||
String get musicProviders;
|
||||
|
||||
/// No description provided for @musicProvidersDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which accounts to show in your library'**
|
||||
String get musicProvidersDescription;
|
||||
|
||||
/// No description provided for @libraryRefreshing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refreshing library...'**
|
||||
String get libraryRefreshing;
|
||||
|
||||
/// No description provided for @cannotDisableLastProvider.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cannot disable the last provider'**
|
||||
String get cannotDisableLastProvider;
|
||||
|
||||
/// No description provided for @noSeriesFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1892,6 +1916,138 @@ abstract class S {
|
||||
/// In en, this message translates to:
|
||||
/// **'Long-press a podcast and tap the heart to add it to favorites'**
|
||||
String get longPressPodcastHint;
|
||||
|
||||
/// Error when playing radio station fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play radio station: {error}'**
|
||||
String failedToPlayRadioStation(String error);
|
||||
|
||||
/// Message when trying to add item already in library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Item is already in library'**
|
||||
String get itemAlreadyInLibrary;
|
||||
|
||||
/// Error when adding to library fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to add to library'**
|
||||
String get failedToAddToLibrary;
|
||||
|
||||
/// Error when library ID not found for removal
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cannot find library ID for removal'**
|
||||
String get cannotFindLibraryId;
|
||||
|
||||
/// Error when removing from library fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to remove from library'**
|
||||
String get failedToRemoveFromLibrary;
|
||||
|
||||
/// Error when playing album fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play album: {error}'**
|
||||
String failedToPlayAlbum(String error);
|
||||
|
||||
/// Status when playing item on specific player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playing {name} on {playerName}'**
|
||||
String playingOnPlayer(String name, String playerName);
|
||||
|
||||
/// Error when playing playlist fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play playlist: {error}'**
|
||||
String failedToPlayPlaylist(String error);
|
||||
|
||||
/// Error when playing audiobook fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play audiobook: {error}'**
|
||||
String failedToPlayAudiobook(String error);
|
||||
|
||||
/// Message when no other players available for transfer
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No other players available'**
|
||||
String get noOtherPlayersAvailable;
|
||||
|
||||
/// Tooltip for transfer queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer queue'**
|
||||
String get transferQueue;
|
||||
|
||||
/// Tooltip for clear queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear queue'**
|
||||
String get clearQueue;
|
||||
|
||||
/// Status when syncing player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Syncing {playerName}...'**
|
||||
String syncingPlayer(String playerName);
|
||||
|
||||
/// Label for switch player action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch Player'**
|
||||
String get switchPlayer;
|
||||
|
||||
/// Confirmation when logs copied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Logs copied to clipboard'**
|
||||
String get logsCopiedToClipboard;
|
||||
|
||||
/// Auto option for language selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto'**
|
||||
String get auto;
|
||||
|
||||
/// Title for transfer queue dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer Queue To'**
|
||||
String get transferQueueTo;
|
||||
|
||||
/// Success message when queue transferred
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Queue transferred to {playerName}'**
|
||||
String queueTransferredTo(String playerName);
|
||||
|
||||
/// Error when queue transfer fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to transfer queue: {error}'**
|
||||
String failedToTransferQueue(String error);
|
||||
|
||||
/// Error when queue loading fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to load queue: {error}'**
|
||||
String failedToLoadQueue(String error);
|
||||
|
||||
/// Label for transfer destination selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer to...'**
|
||||
String get transferTo;
|
||||
|
||||
/// Tooltip for transfer queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer queue to another player'**
|
||||
String get transferQueueToPlayer;
|
||||
}
|
||||
|
||||
class _SDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -796,6 +796,18 @@ class SDe extends S {
|
||||
@override
|
||||
String get unknownLibrary => 'Unbekannte Bibliothek';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Keine Serien gefunden';
|
||||
|
||||
@@ -980,4 +992,88 @@ class SDe extends S {
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
}
|
||||
|
||||
@@ -796,6 +796,18 @@ class SEn extends S {
|
||||
@override
|
||||
String get unknownLibrary => 'Unknown Library';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No Series Found';
|
||||
|
||||
@@ -980,4 +992,88 @@ class SEn extends S {
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
}
|
||||
|
||||
@@ -796,6 +796,18 @@ class SEs extends S {
|
||||
@override
|
||||
String get unknownLibrary => 'Biblioteca desconocida';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No se encontraron series';
|
||||
|
||||
@@ -980,4 +992,88 @@ class SEs extends S {
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
}
|
||||
|
||||
@@ -796,6 +796,18 @@ class SFr extends S {
|
||||
@override
|
||||
String get unknownLibrary => 'Bibliothèque inconnue';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription => 'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Aucune série trouvee';
|
||||
|
||||
@@ -980,4 +992,88 @@ class SFr extends S {
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ class ProviderMapping {
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
/// Whether this item is in the library for this specific provider instance.
|
||||
/// True = this provider "owns" the item (user added it from this account)
|
||||
/// False = item exists but wasn't added from this provider
|
||||
final bool inLibrary;
|
||||
|
||||
ProviderMapping({
|
||||
required this.itemId,
|
||||
@@ -23,6 +27,7 @@ class ProviderMapping {
|
||||
required this.providerInstance,
|
||||
required this.available,
|
||||
this.audioFormat,
|
||||
this.inLibrary = true,
|
||||
});
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
@@ -32,6 +37,8 @@ class ProviderMapping {
|
||||
providerInstance: json['provider_instance'] as String? ?? '',
|
||||
available: json['available'] as bool? ?? true,
|
||||
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
||||
// Parse in_library field - MA uses 1/0 or true/false
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +49,7 @@ class ProviderMapping {
|
||||
'provider_instance': providerInstance,
|
||||
'available': available,
|
||||
if (audioFormat != null) 'audio_format': audioFormat,
|
||||
'in_library': inLibrary,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -58,10 +66,6 @@ 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,
|
||||
@@ -75,10 +79,6 @@ class MediaItem {
|
||||
this.favorite,
|
||||
this.position,
|
||||
this.duration,
|
||||
this.timestampAdded,
|
||||
this.timestampModified,
|
||||
this.lastPlayed,
|
||||
this.playCount,
|
||||
});
|
||||
|
||||
factory MediaItem.fromJson(Map<String, dynamic> json) {
|
||||
@@ -91,21 +91,6 @@ 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',
|
||||
@@ -122,10 +107,6 @@ 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?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,10 +124,6 @@ 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -161,10 +138,6 @@ 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) {
|
||||
@@ -178,10 +151,6 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,10 +175,6 @@ 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) {
|
||||
@@ -237,10 +202,6 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,10 +248,6 @@ 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) {
|
||||
@@ -312,10 +269,6 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,10 +303,6 @@ 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) {
|
||||
@@ -370,10 +319,6 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Represents a music provider instance from Music Assistant.
|
||||
///
|
||||
/// Each provider (like Spotify, Tidal) can have multiple instances
|
||||
/// (e.g., two different Spotify accounts). This class represents
|
||||
/// a single provider instance with its unique ID.
|
||||
class ProviderInstance {
|
||||
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
|
||||
final String instanceId;
|
||||
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this instance (e.g., "Spotify (John's Account)")
|
||||
final String name;
|
||||
|
||||
/// Whether this provider is currently available/connected
|
||||
final bool available;
|
||||
|
||||
ProviderInstance({
|
||||
required this.instanceId,
|
||||
required this.domain,
|
||||
required this.name,
|
||||
required this.available,
|
||||
});
|
||||
|
||||
/// Provider capabilities - maps domain to supported content types
|
||||
/// This determines which tabs/categories show which providers
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services (support artists, albums, tracks, playlists)
|
||||
// Spotify also has audiobooks and podcasts
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Self-hosted media servers (can have music, audiobooks, and podcasts)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Audiobook and podcast providers
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'},
|
||||
// Radio providers (tunein also has podcasts)
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
'radiobrowser': {'radio'},
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
|
||||
/// Known music provider domains that provide library content
|
||||
static const Set<String> musicProviderDomains = {
|
||||
// Streaming services
|
||||
'spotify',
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'deezer',
|
||||
'ytmusic',
|
||||
'soundcloud',
|
||||
'apple_music',
|
||||
'amazon_music',
|
||||
// Self-hosted / local
|
||||
'plex',
|
||||
'subsonic',
|
||||
'opensubsonic',
|
||||
'jellyfin',
|
||||
'emby',
|
||||
'navidrome',
|
||||
'audiobookshelf',
|
||||
'filesystem',
|
||||
'itunes_podcasts',
|
||||
// Other
|
||||
'tunein',
|
||||
'radiobrowser',
|
||||
'snapcast',
|
||||
'fully_kiosk',
|
||||
};
|
||||
|
||||
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
|
||||
/// Get supported content types for this provider
|
||||
Set<String> get supportedContentTypes =>
|
||||
providerCapabilities[domain] ?? <String>{};
|
||||
|
||||
/// Check if this provider supports a specific content type/category
|
||||
bool supportsContentType(String category) =>
|
||||
supportedContentTypes.contains(category);
|
||||
|
||||
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderInstance(
|
||||
instanceId: json['instance_id'] as String? ?? '',
|
||||
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
|
||||
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
|
||||
available: json['available'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'instance_id': instanceId,
|
||||
'domain': domain,
|
||||
'name': name,
|
||||
'available': available,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderInstance($instanceId: $name)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProviderInstance &&
|
||||
runtimeType == other.runtimeType &&
|
||||
instanceId == other.instanceId;
|
||||
|
||||
@override
|
||||
int get hashCode => instanceId.hashCode;
|
||||
}
|
||||
@@ -6,8 +6,8 @@ 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 '../models/provider_instance.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -107,14 +107,21 @@ 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 DISABLED provider instance IDs from local app settings
|
||||
// Empty list means all providers are enabled (shown)
|
||||
List<String> _disabledProviders = [];
|
||||
// Provider filter: list of allowed provider instance IDs from MA user settings
|
||||
// Empty list means all providers are allowed
|
||||
List<String> _providerFilter = [];
|
||||
|
||||
// Player filter: list of allowed player IDs from MA user settings
|
||||
// Empty list means all players are allowed
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// User-controlled music provider filter (local settings in Ensemble)
|
||||
// Empty list means all providers are enabled (no filtering)
|
||||
List<String> _enabledProviderIds = [];
|
||||
|
||||
// Available music providers discovered from MA
|
||||
List<ProviderInstance> _availableMusicProviders = [];
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
@@ -126,14 +133,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
// Library getters with provider filtering applied
|
||||
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<Artist> get artists => filterByProvider(_artists);
|
||||
List<Album> get albums => filterByProvider(_albums);
|
||||
List<Track> get tracks => filterByProvider(_tracks);
|
||||
List<MediaItem> get radioStations => filterByProvider(_radioStations);
|
||||
List<MediaItem> get podcasts => filterByProvider(_podcasts);
|
||||
@@ -141,149 +142,60 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Raw unfiltered access (for internal use only)
|
||||
List<Artist> get artistsUnfiltered => _artists;
|
||||
List<Album> get albumsUnfiltered => _albums;
|
||||
List<MediaItem> get podcastsUnfiltered => _podcasts;
|
||||
List<MediaItem> get radioStationsUnfiltered => _radioStations;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingRadio => _isLoadingRadio;
|
||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||
|
||||
/// Available music providers from MA server
|
||||
List<MusicProvider> get musicProviders => _api?.musicProviders ?? [];
|
||||
/// Provider filter from MA user settings (empty = all providers allowed)
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
||||
|
||||
/// Check if a media item should be visible based on provider filter
|
||||
/// Returns true if item has at least one ENABLED provider
|
||||
/// Hide only if ALL of the item's providers are disabled
|
||||
int _filterDebugCount = 0;
|
||||
/// 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
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
// No disabled providers = show everything
|
||||
if (_disabledProviders.isEmpty) return true;
|
||||
// No filter = show everything
|
||||
if (_providerFilter.isEmpty) return true;
|
||||
|
||||
// Check if item's provider mappings include any allowed provider
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null && mappings.isNotEmpty) {
|
||||
// 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++;
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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++;
|
||||
// Also check primary provider field (for items without full mappings)
|
||||
if (_providerFilter.contains(item.provider)) {
|
||||
return true;
|
||||
}
|
||||
return allowed;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Filter a list of media items based on provider filter
|
||||
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||
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;
|
||||
if (_providerFilter.isEmpty) return items;
|
||||
return items.where(isItemAllowedByProviderFilter).toList();
|
||||
}
|
||||
|
||||
/// Filter search results map based on provider filter
|
||||
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||
if (_disabledProviders.isEmpty) return results;
|
||||
if (_providerFilter.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;
|
||||
|
||||
@@ -302,6 +214,210 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return players.where(isPlayerAllowedByFilter).toList();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER-CONTROLLED MUSIC PROVIDER FILTER
|
||||
// ============================================================================
|
||||
|
||||
/// List of available music providers from MA
|
||||
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
|
||||
|
||||
/// List of enabled provider instance IDs (empty = all enabled)
|
||||
List<String> get enabledProviderIds => _enabledProviderIds;
|
||||
|
||||
/// Whether user has enabled specific providers (not all)
|
||||
bool get hasUserProviderFilter => _enabledProviderIds.isNotEmpty;
|
||||
|
||||
/// Get provider IDs to pass to API calls
|
||||
/// Returns null if all providers are enabled (no filtering needed)
|
||||
/// Returns the list of enabled provider IDs otherwise
|
||||
List<String>? get providerIdsForApiCalls {
|
||||
if (_enabledProviderIds.isEmpty) return null; // All enabled
|
||||
return _enabledProviderIds;
|
||||
}
|
||||
|
||||
/// Check if a specific provider is enabled by the user
|
||||
bool isProviderEnabled(String instanceId) {
|
||||
if (_enabledProviderIds.isEmpty) return true; // All enabled by default
|
||||
return _enabledProviderIds.contains(instanceId);
|
||||
}
|
||||
|
||||
/// Get providers that have content in artists library (with item counts)
|
||||
Map<String, int> getProvidersWithArtists() {
|
||||
return _getProvidersWithCounts(_artists);
|
||||
}
|
||||
|
||||
/// Get providers that have content in albums library (with item counts)
|
||||
Map<String, int> getProvidersWithAlbums() {
|
||||
return _getProvidersWithCounts(_albums);
|
||||
}
|
||||
|
||||
/// Get providers that have content in tracks library (with item counts)
|
||||
Map<String, int> getProvidersWithTracks() {
|
||||
return _getProvidersWithCounts(_tracks);
|
||||
}
|
||||
|
||||
/// Get providers that have content in playlists (with item counts)
|
||||
Map<String, int> getProvidersWithPlaylists() {
|
||||
return _getProvidersWithCounts(SyncService.instance.cachedPlaylists);
|
||||
}
|
||||
|
||||
/// Get providers that have audiobooks (with item counts)
|
||||
Map<String, int> getProvidersWithAudiobooks() {
|
||||
return _getProvidersWithCounts(SyncService.instance.cachedAudiobooks);
|
||||
}
|
||||
|
||||
/// Get providers that have radio stations (with item counts)
|
||||
Map<String, int> getProvidersWithRadio() {
|
||||
return _getProvidersWithCounts(_radioStations);
|
||||
}
|
||||
|
||||
/// Get providers that have podcasts (with item counts)
|
||||
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||
/// the user actually added the podcast from (vs providers that can play it)
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in _podcasts) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item (user added it from this account)
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Internal helper to count items per provider from a list of media items
|
||||
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||
/// the user actually added the item from (vs providers that can play it)
|
||||
Map<String, int> _getProvidersWithCounts<T extends MediaItem>(List<T> items) {
|
||||
final counts = <String, int>{};
|
||||
for (final item in items) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item (user added it from this account)
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Get ProviderInstance objects for providers that have content in a category
|
||||
/// Returns list of (ProviderInstance, itemCount) tuples, sorted by name
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
final Map<String, int> counts;
|
||||
switch (category) {
|
||||
case 'artists':
|
||||
counts = getProvidersWithArtists();
|
||||
break;
|
||||
case 'albums':
|
||||
counts = getProvidersWithAlbums();
|
||||
break;
|
||||
case 'tracks':
|
||||
counts = getProvidersWithTracks();
|
||||
break;
|
||||
case 'playlists':
|
||||
counts = getProvidersWithPlaylists();
|
||||
break;
|
||||
case 'audiobooks':
|
||||
counts = getProvidersWithAudiobooks();
|
||||
break;
|
||||
case 'radio':
|
||||
counts = getProvidersWithRadio();
|
||||
break;
|
||||
case 'podcasts':
|
||||
counts = getProvidersWithPodcasts();
|
||||
break;
|
||||
default:
|
||||
counts = {};
|
||||
}
|
||||
|
||||
// Build list of providers that support this category, with their item counts
|
||||
// Only include providers that have at least 1 item (hides providers not synced to library)
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
final addedInstanceIds = <String>{};
|
||||
|
||||
for (final provider in _availableMusicProviders) {
|
||||
// Only include providers that support this content type AND have items
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
if (count > 0) {
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also include providers that have items but aren't in _availableMusicProviders
|
||||
// (e.g., search-only providers like iTunes)
|
||||
for (final entry in counts.entries) {
|
||||
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
|
||||
// Create a synthetic ProviderInstance from the instance ID
|
||||
// Format is typically "domain--uniqueId" (e.g., "itunes--abc123")
|
||||
final instanceId = entry.key;
|
||||
final domain = instanceId.contains('--')
|
||||
? instanceId.split('--').first
|
||||
: instanceId;
|
||||
// Only add if this domain supports the category
|
||||
final capabilities = ProviderInstance.providerCapabilities[domain];
|
||||
if (capabilities != null && capabilities.contains(category)) {
|
||||
result.add((
|
||||
ProviderInstance(
|
||||
instanceId: instanceId,
|
||||
domain: domain,
|
||||
name: _formatProviderName(domain),
|
||||
available: true,
|
||||
),
|
||||
entry.value,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name
|
||||
result.sort((a, b) => a.$1.name.compareTo(b.$1.name));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Format a provider domain into a readable display name
|
||||
String _formatProviderName(String domain) {
|
||||
const names = {
|
||||
'spotify': 'Spotify',
|
||||
'tidal': 'Tidal',
|
||||
'qobuz': 'Qobuz',
|
||||
'deezer': 'Deezer',
|
||||
'ytmusic': 'YouTube Music',
|
||||
'soundcloud': 'SoundCloud',
|
||||
'apple_music': 'Apple Music',
|
||||
'amazon_music': 'Amazon Music',
|
||||
'plex': 'Plex',
|
||||
'jellyfin': 'Jellyfin',
|
||||
'emby': 'Emby',
|
||||
'subsonic': 'Subsonic',
|
||||
'opensubsonic': 'OpenSubsonic',
|
||||
'navidrome': 'Navidrome',
|
||||
'audiobookshelf': 'Audiobookshelf',
|
||||
'filesystem': 'Filesystem',
|
||||
'itunes_podcasts': 'iTunes Podcasts',
|
||||
'tunein': 'TuneIn',
|
||||
'radiobrowser': 'Radio Browser',
|
||||
};
|
||||
return names[domain] ?? domain[0].toUpperCase() + domain.substring(1);
|
||||
}
|
||||
|
||||
/// Whether library is syncing in background
|
||||
bool get isSyncing => SyncService.instance.isSyncing;
|
||||
|
||||
@@ -475,7 +591,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults);
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
|
||||
MusicAssistantAPI? get api => _api;
|
||||
AuthManager get authManager => _authManager;
|
||||
@@ -1014,11 +1130,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
}
|
||||
|
||||
// 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 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');
|
||||
}
|
||||
|
||||
// Capture player filter from MA user settings (empty list = all players allowed)
|
||||
// Capture player filter (empty list = all players allowed)
|
||||
final playerFilterRaw = userInfo['player_filter'];
|
||||
if (playerFilterRaw is List) {
|
||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||
@@ -1037,6 +1164,83 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load available music providers from MA and restore saved filter settings
|
||||
Future<void> loadMusicProviders() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_logger.log('🎵 Loading music providers...');
|
||||
|
||||
// Fetch available providers from MA
|
||||
_availableMusicProviders = await _api!.getMusicProviders();
|
||||
|
||||
// Save discovered providers to settings
|
||||
final discoveredList = _availableMusicProviders.map((p) => {
|
||||
'instanceId': p.instanceId,
|
||||
'domain': p.domain,
|
||||
'name': p.name,
|
||||
}).toList();
|
||||
await SettingsService.setDiscoveredMusicProviders(discoveredList);
|
||||
|
||||
// Load saved enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
if (savedEnabled != null) {
|
||||
// Validate saved IDs against available providers
|
||||
final availableIds = _availableMusicProviders.map((p) => p.instanceId).toSet();
|
||||
_enabledProviderIds = savedEnabled.where((id) => availableIds.contains(id)).toList();
|
||||
|
||||
// If all saved providers are gone, reset to all enabled
|
||||
if (_enabledProviderIds.isEmpty && savedEnabled.isNotEmpty) {
|
||||
_enabledProviderIds = [];
|
||||
await SettingsService.clearEnabledMusicProviders();
|
||||
_logger.log('🔄 Reset provider filter - saved providers no longer available');
|
||||
} else if (_enabledProviderIds.isNotEmpty) {
|
||||
_logger.log('🔒 Loaded provider filter: ${_enabledProviderIds.length} providers enabled');
|
||||
}
|
||||
} else {
|
||||
_enabledProviderIds = [];
|
||||
_logger.log('🔓 No provider filter - all ${_availableMusicProviders.length} providers enabled');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not load music providers (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a specific music provider on/off
|
||||
/// Uses debounce to allow multiple toggles before syncing
|
||||
Future<bool> toggleProviderEnabled(String instanceId, bool enabled) async {
|
||||
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
|
||||
|
||||
// Don't allow disabling the last provider
|
||||
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
|
||||
_logger.log('⚠️ Cannot disable last provider');
|
||||
return false;
|
||||
}
|
||||
|
||||
await SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
|
||||
|
||||
// Reload the enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
_enabledProviderIds = savedEnabled ?? [];
|
||||
|
||||
_logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}');
|
||||
|
||||
// Clear discover caches so they get refetched with new filter
|
||||
_cacheService.clearDiscoverCaches();
|
||||
|
||||
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
|
||||
// This enables instant UI updates using cached data with source tracking
|
||||
notifyListeners();
|
||||
|
||||
// NOTE: Debounced sync is handled by the UI layer (new_library_screen.dart)
|
||||
// to avoid duplicate timers and ensure single point of control
|
||||
|
||||
// Return true to indicate UI should update
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _initializeAfterConnection() async {
|
||||
if (_api == null) return;
|
||||
|
||||
@@ -1049,6 +1253,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
await _fetchUserSettings();
|
||||
}
|
||||
|
||||
// Load available music providers and user's filter preferences
|
||||
await loadMusicProviders();
|
||||
|
||||
await _tryAdoptGhostPlayer();
|
||||
await _registerLocalPlayer();
|
||||
await _loadAndSelectPlayers(coldStart: true);
|
||||
@@ -2807,46 +3014,44 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover artists');
|
||||
return filterByProvider(_cacheService.getCachedDiscoverArtists()!);
|
||||
return _cacheService.getCachedDiscoverArtists()!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
final cached = _cacheService.getCachedDiscoverArtists();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
}
|
||||
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover artists...');
|
||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||
final artists = await _api!.getRandomArtists(
|
||||
limit: LibraryConstants.defaultRecentLimit,
|
||||
providerInstanceIds: providerIdsForApiCalls,
|
||||
);
|
||||
_cacheService.setCachedDiscoverArtists(artists);
|
||||
return filterByProvider(artists);
|
||||
return artists;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||
final cached = _cacheService.getCachedDiscoverArtists();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover albums');
|
||||
return filterByProvider(_cacheService.getCachedDiscoverAlbums()!);
|
||||
return _cacheService.getCachedDiscoverAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
}
|
||||
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover albums...');
|
||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
final albums = await _api!.getRandomAlbums(
|
||||
limit: LibraryConstants.defaultRecentLimit,
|
||||
providerInstanceIds: providerIdsForApiCalls,
|
||||
);
|
||||
_cacheService.setCachedDiscoverAlbums(albums);
|
||||
return filterByProvider(albums);
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2854,31 +3059,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
|
||||
/// 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 recent albums synchronously (for instant display)
|
||||
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
|
||||
|
||||
/// 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 artists synchronously (for instant display)
|
||||
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
|
||||
|
||||
/// Get cached discover albums synchronously (for instant display, with filtering)
|
||||
List<Album>? getCachedDiscoverAlbums() {
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
/// Get cached discover albums synchronously (for instant display)
|
||||
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
|
||||
|
||||
/// Force a full library sync (for pull-to-refresh)
|
||||
Future<void> forceLibrarySync() async {
|
||||
if (_api == null) return;
|
||||
if (_api == null) {
|
||||
_logger.log('⚠️ forceLibrarySync: API is null, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_logger.log('🔄 Forcing full library sync (provider=$providerFilter)...');
|
||||
await SyncService.instance.forceSync(_api!, provider: providerFilter);
|
||||
final providerIds = providerIdsForApiCalls;
|
||||
_logger.log('🔄 Forcing full library sync with providers: ${providerIds ?? "ALL"}');
|
||||
await SyncService.instance.forceSync(_api!, providerInstanceIds: providerIds);
|
||||
|
||||
// Update local lists from sync result
|
||||
_albums = SyncService.instance.cachedAlbums;
|
||||
@@ -2886,7 +3085,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Also refresh tracks from API
|
||||
try {
|
||||
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
|
||||
_tracks = await _api!.getTracks(
|
||||
limit: LibraryConstants.maxLibraryItems,
|
||||
providerInstanceIds: providerIdsForApiCalls,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to refresh tracks: $e');
|
||||
}
|
||||
@@ -2899,36 +3101,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// FAVORITES FOR HOME SCREEN
|
||||
// ============================================================================
|
||||
|
||||
/// Get favorite albums from the library (with provider filtering)
|
||||
/// Get favorite albums from the library
|
||||
Future<List<Album>> getFavoriteAlbums() async {
|
||||
// Filter from loaded library - favorites are already loaded
|
||||
// Use filtered albums to respect provider settings
|
||||
return albums.where((a) => a.favorite == true).toList();
|
||||
return _albums.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
/// Get favorite artists from the library (with provider filtering)
|
||||
/// Get favorite artists from the library
|
||||
Future<List<Artist>> getFavoriteArtists() async {
|
||||
// Filter from loaded library - favorites are already loaded
|
||||
// Use filtered artists to respect provider settings
|
||||
return artists.where((a) => a.favorite == true).toList();
|
||||
return _artists.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
/// Get favorite tracks from the library (with provider filtering)
|
||||
/// Get favorite tracks from the library
|
||||
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 (using unfiltered for persistence)
|
||||
final unfilteredFavTracks = _tracks.where((t) => t.favorite == true).toList();
|
||||
if (unfilteredFavTracks.isNotEmpty && unfilteredFavTracks.length != _cachedFavoriteTracks.length) {
|
||||
_cachedFavoriteTracks = unfilteredFavTracks;
|
||||
_persistFavoriteTracks(unfilteredFavTracks);
|
||||
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);
|
||||
}
|
||||
return favTracks;
|
||||
}
|
||||
// Otherwise return cached favorites (for instant display before full library loads)
|
||||
return filterByProvider(_cachedFavoriteTracks);
|
||||
return _cachedFavoriteTracks;
|
||||
}
|
||||
|
||||
/// Get favorite playlists from the library
|
||||
@@ -2975,13 +3173,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Check cache first
|
||||
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
|
||||
_logger.log('📦 Using cached in-progress audiobooks');
|
||||
return filterByProvider(_cacheService.getCachedInProgressAudiobooks()!);
|
||||
return _cacheService.getCachedInProgressAudiobooks()!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
// Fallback to cache when offline
|
||||
final cached = _cacheService.getCachedInProgressAudiobooks();
|
||||
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -3001,29 +3199,26 @@ 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 filterByProvider(cached);
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 cached in-progress audiobooks synchronously (for instant display)
|
||||
List<Audiobook>? getCachedInProgressAudiobooks() => _cacheService.getCachedInProgressAudiobooks();
|
||||
|
||||
/// 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 filterByProvider(_cacheService.getCachedDiscoverAudiobooks()!);
|
||||
return _cacheService.getCachedDiscoverAudiobooks()!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
// Fallback to cache when offline
|
||||
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -3040,16 +3235,13 @@ 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 filterByProvider(cached);
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached discover audiobooks synchronously (for instant display, with filtering)
|
||||
List<Audiobook>? getCachedDiscoverAudiobooks() {
|
||||
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
/// Get cached discover audiobooks synchronously (for instant display)
|
||||
List<Audiobook>? getCachedDiscoverAudiobooks() => _cacheService.getCachedDiscoverAudiobooks();
|
||||
|
||||
/// Get random series for discovery with caching
|
||||
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
|
||||
@@ -3204,12 +3396,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
|
||||
return filterSearchResults(_cacheService.getCachedSearchResults(cacheKey)!);
|
||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
return filterSearchResults(cached);
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -3226,11 +3417,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||
_logger.log('✅ Cached search results for "$query"');
|
||||
return filterSearchResults(cachedResults);
|
||||
return cachedResults;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Search failed: $e');
|
||||
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
return filterSearchResults(cached);
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4517,15 +4707,13 @@ 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 {
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_tracks = await _api!.getTracks(
|
||||
limit: LibraryConstants.maxLibraryItems,
|
||||
provider: providerFilter,
|
||||
providerInstanceIds: providerIdsForApiCalls,
|
||||
);
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA (provider=$providerFilter)');
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to fetch tracks: $e');
|
||||
}
|
||||
@@ -4551,8 +4739,6 @@ 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() {
|
||||
@@ -4566,7 +4752,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
syncService.addListener(onSyncComplete);
|
||||
await syncService.syncFromApi(_api!, provider: providerFilter);
|
||||
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
|
||||
}
|
||||
|
||||
/// Load radio stations from the library
|
||||
@@ -4713,7 +4899,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
try {
|
||||
final results = await _api!.search(query, libraryOnly: libraryOnly);
|
||||
return filterSearchResults(results);
|
||||
return results;
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Search', e);
|
||||
return {'artists': [], 'albums': [], 'tracks': []};
|
||||
|
||||
@@ -223,7 +223,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
@@ -254,12 +253,35 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'album',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add album to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -280,13 +302,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
@@ -294,13 +310,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove album from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling album library: $e');
|
||||
@@ -673,8 +701,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
void _navigateToArtist() {
|
||||
// Navigate to the first artist if available
|
||||
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
|
||||
final artist = widget.album.artists!.first;
|
||||
if (_displayAlbum.artists != null && _displayAlbum.artists!.isNotEmpty) {
|
||||
final artist = _displayAlbum.artists!.first;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||
Navigator.push(
|
||||
@@ -914,7 +942,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
_displayAlbum.artistsString,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
|
||||
@@ -308,7 +308,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
@@ -339,12 +338,35 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'artist',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add artist to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -365,13 +387,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
@@ -379,13 +395,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove artist from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling artist library: $e');
|
||||
|
||||
+705
-203
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
@@ -97,12 +96,35 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'podcast',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add podcast to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
@@ -123,13 +145,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'podcast',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
@@ -137,13 +153,25 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'podcast',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove podcast from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling podcast library: $e');
|
||||
@@ -190,10 +218,38 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
final episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
List<MediaItem> episodes = [];
|
||||
|
||||
// Try loading with the podcast's own ID and provider first
|
||||
try {
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Primary episode load failed: $e');
|
||||
}
|
||||
|
||||
// If that failed and we have provider mappings, try those
|
||||
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
|
||||
for (final mapping in widget.podcast.providerMappings!) {
|
||||
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
|
||||
try {
|
||||
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
mapping.itemId,
|
||||
provider: mapping.providerInstance,
|
||||
);
|
||||
if (episodes.isNotEmpty) {
|
||||
_logger.log('🎙️ Loaded episodes via provider mapping');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Provider mapping failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
|
||||
|
||||
@@ -85,9 +85,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
|
||||
if (targetPlayers.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No other players available to transfer to'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.noOtherPlayersAvailable),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -96,7 +96,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
// Show player picker sheet
|
||||
await showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: 'Transfer Queue To',
|
||||
title: S.of(context)!.transferQueueTo,
|
||||
players: targetPlayers,
|
||||
onPlayerSelected: (targetPlayer) async {
|
||||
try {
|
||||
@@ -109,7 +109,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Queue transferred to ${targetPlayer.name}'),
|
||||
content: Text(S.of(context)!.queueTransferredTo(targetPlayer.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -119,7 +119,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to transfer queue: $e'),
|
||||
content: Text(S.of(context)!.failedToTransferQueue(e.toString())),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -152,7 +152,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz_rounded),
|
||||
tooltip: 'Transfer queue',
|
||||
tooltip: S.of(context)!.transferQueue,
|
||||
onPressed: () => _handleTransferQueue(maProvider),
|
||||
),
|
||||
IconButton(
|
||||
|
||||
@@ -145,9 +145,6 @@ 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();
|
||||
@@ -156,52 +153,43 @@ 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 (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');
|
||||
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');
|
||||
_cachedAvailableFilters = filters;
|
||||
return filters;
|
||||
}
|
||||
|
||||
/// Filter search results by enabled providers (only when in-library mode is on)
|
||||
/// Uses the client-side provider toggles from the library screen
|
||||
Map<String, List<MediaItem>> _filterByEnabledProviders(
|
||||
Map<String, List<MediaItem>> results,
|
||||
Set<String> enabledProviders,
|
||||
) {
|
||||
if (enabledProviders.isEmpty) return results; // No filter = show all
|
||||
|
||||
bool isItemAllowed(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings == null || mappings.isEmpty) return true; // No mappings = show
|
||||
// Item is allowed if it has inLibrary=true for any enabled provider
|
||||
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowed).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Animate to a specific filter
|
||||
void _animateToFilter(String filter) {
|
||||
final filters = _getAvailableFilters();
|
||||
@@ -461,12 +449,21 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Combine all results
|
||||
var combinedResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
|
||||
// When "in library" mode is on, also filter by enabled providers
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
_searchResults = combinedResults;
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||
@@ -724,16 +721,13 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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 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>? ?? [];
|
||||
|
||||
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty ||
|
||||
playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty ||
|
||||
@@ -2421,7 +2415,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play radio station: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2618,7 +2612,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2669,7 +2663,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// Item is library-only, can't add
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Item is already in library')),
|
||||
SnackBar(content: Text(S.of(context)!.itemAlreadyInLibrary)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -2705,7 +2699,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to add to library')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToAddToLibrary)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2732,7 +2726,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
|
||||
if (libraryItemId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot find library ID for removal')),
|
||||
SnackBar(content: Text(S.of(context)!.cannotFindLibraryId)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2764,7 +2758,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to remove from library')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToRemoveFromLibrary)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2908,7 +2902,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling artist favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2939,7 +2933,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play album: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2963,7 +2957,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${album.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(album.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -2971,7 +2965,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play album: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3087,7 +3081,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling album favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3118,7 +3112,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play playlist: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3142,7 +3136,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${playlist.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(playlist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3150,7 +3144,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play playlist: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3177,7 +3171,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${audiobook.name}'),
|
||||
content: Text(S.of(context)!.playing(audiobook.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3185,7 +3179,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play audiobook: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3209,7 +3203,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${audiobook.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(audiobook.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3217,7 +3211,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play audiobook: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3302,7 +3296,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_logger.log('Error toggling audiobook favorite: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update favorite: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3326,7 +3320,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${radio.name} on ${player.name}'),
|
||||
content: Text(S.of(context)!.playingOnPlayer(radio.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -3334,7 +3328,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play radio station: $e')),
|
||||
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,19 +38,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showDiscoverSeries = false;
|
||||
// Home row order
|
||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||
// Audiobook libraries
|
||||
List<Map<String, String>> _discoveredLibraries = [];
|
||||
Map<String, bool> _libraryEnabled = {};
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
// Library settings
|
||||
bool _showOnlyArtistsWithAlbums = false;
|
||||
// Music provider filter settings
|
||||
List<String> _disabledProviders = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -88,16 +81,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load home row order
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
|
||||
// Load audiobook library settings
|
||||
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
|
||||
final enabled = await SettingsService.getEnabledAbsLibraries();
|
||||
final libraryEnabled = <String, bool>{};
|
||||
for (final lib in discovered) {
|
||||
final path = lib['path'] ?? '';
|
||||
// null means all enabled
|
||||
libraryEnabled[path] = enabled == null || enabled.contains(path);
|
||||
}
|
||||
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
@@ -106,12 +89,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
|
||||
// Load library settings
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
|
||||
// Load music provider filter settings
|
||||
final disabledProviders = await SettingsService.getDisabledMusicProviders();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -127,14 +104,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveredLibraries = discovered;
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||
_disabledProviders = disabledProviders;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -655,7 +628,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
RadioListTile<String?>(
|
||||
title: Text(S.of(context)!.system),
|
||||
subtitle: const Text('Auto'),
|
||||
subtitle: Text(S.of(context)!.auto),
|
||||
value: null,
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -664,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('English'),
|
||||
title: Text(S.of(context)!.english),
|
||||
value: 'en',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -673,7 +646,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Deutsch'),
|
||||
title: Text(S.of(context)!.german),
|
||||
value: 'de',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -682,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Español'),
|
||||
title: Text(S.of(context)!.spanish),
|
||||
value: 'es',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -691,7 +664,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Français'),
|
||||
title: Text(S.of(context)!.french),
|
||||
value: 'fr',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -711,66 +684,6 @@ 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,
|
||||
@@ -900,49 +813,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Library section
|
||||
Text(
|
||||
S.of(context)!.library,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
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(
|
||||
'Show only artists with albums',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Hide artists that have no albums in your library',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
value: _showOnlyArtistsWithAlbums,
|
||||
onChanged: (value) async {
|
||||
setState(() => _showOnlyArtistsWithAlbums = value);
|
||||
await SettingsService.setShowOnlyArtistsWithAlbums(value);
|
||||
// Force sync library to apply the new filter at API level
|
||||
if (mounted) {
|
||||
context.read<MusicAssistantProvider>().forceLibrarySync();
|
||||
}
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Home Screen section
|
||||
Text(
|
||||
S.of(context)!.homeScreen,
|
||||
@@ -1119,76 +989,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Audiobook Libraries section
|
||||
if (_discoveredLibraries.isNotEmpty) ...[
|
||||
Text(
|
||||
S.of(context)!.audiobookLibraries,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.chooseAudiobookLibraries,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: _discoveredLibraries.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final library = entry.value;
|
||||
final path = library['path'] ?? '';
|
||||
final name = library['name'] ?? S.of(context)!.unknownLibrary;
|
||||
final isEnabled = _libraryEnabled[path] ?? true;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
name,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
value: isEnabled,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_libraryEnabled[path] = value;
|
||||
});
|
||||
await SettingsService.toggleAbsLibrary(path, value);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.pullToRefresh),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
),
|
||||
if (index < _discoveredLibraries.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
|
||||
@@ -213,6 +213,20 @@ class CacheService {
|
||||
_logger.log('🗑️ Home artist caches invalidated');
|
||||
}
|
||||
|
||||
/// Clear discover caches (call when provider filter changes)
|
||||
/// This ensures discover rows are refetched with the new provider filter
|
||||
void clearDiscoverCaches() {
|
||||
_cachedDiscoverArtists = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_cachedDiscoverAlbums = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_cachedRecentAlbums = null;
|
||||
_recentAlbumsLastFetched = null;
|
||||
_searchCache.clear();
|
||||
_searchCacheTime.clear();
|
||||
_logger.log('🗑️ Discover caches cleared for provider filter change');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN CACHING
|
||||
// ============================================================================
|
||||
|
||||
@@ -107,15 +107,18 @@ class DatabaseService {
|
||||
// ============================================
|
||||
|
||||
/// Cache an item with JSON serialization
|
||||
/// [sourceProvider] - optional provider instance ID that provided this item (for client-side filtering)
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider,
|
||||
}) {
|
||||
return db.cacheItem(
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: jsonEncode(data),
|
||||
sourceProvider: sourceProvider,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,6 +135,29 @@ class DatabaseService {
|
||||
}).where((item) => item.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Get cached items with source provider info for client-side filtering
|
||||
/// Returns list of (data, sourceProviders) tuples
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType) async {
|
||||
final items = await db.getCachedItems(itemType);
|
||||
return items.map((item) {
|
||||
try {
|
||||
final data = jsonDecode(item.data) as Map<String, dynamic>;
|
||||
List<String> providers = [];
|
||||
try {
|
||||
if (item.sourceProviders.isNotEmpty && item.sourceProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
jsonDecode(item.sourceProviders) as List,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
return (data, providers);
|
||||
} catch (e) {
|
||||
_logger.log('Error decoding cached item: $e');
|
||||
return (<String, dynamic>{}, <String>[]);
|
||||
}
|
||||
}).where((item) => item.$1.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Get a single cached item
|
||||
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
|
||||
final item = await db.getCachedItem(itemType, itemId);
|
||||
|
||||
@@ -7,8 +7,8 @@ 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 '../models/provider_instance.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -53,10 +53,6 @@ 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;
|
||||
@@ -382,7 +378,7 @@ class MusicAssistantAPI {
|
||||
bool? favoriteOnly,
|
||||
bool albumArtistsOnly = true,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final args = {
|
||||
@@ -392,7 +388,8 @@ class MusicAssistantAPI {
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (provider != null) 'provider': provider,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
};
|
||||
_logger.log('🔍 API getArtists args: $args');
|
||||
final response = await _sendCommand(
|
||||
@@ -414,7 +411,11 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true, String? provider}) async {
|
||||
Future<List<Artist>> getRandomArtists({
|
||||
int limit = 10,
|
||||
bool albumArtistsOnly = true,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
@@ -422,7 +423,8 @@ class MusicAssistantAPI {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
if (provider != null) 'provider': provider,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -445,7 +447,7 @@ class MusicAssistantAPI {
|
||||
bool? favoriteOnly,
|
||||
String? artistId,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final args = <String, dynamic>{
|
||||
@@ -455,7 +457,8 @@ class MusicAssistantAPI {
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (artistId != null) 'artist_id': artistId,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (provider != null) 'provider': provider,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
};
|
||||
_logger.log('🔍 API getAlbums args: $args');
|
||||
|
||||
@@ -485,7 +488,7 @@ class MusicAssistantAPI {
|
||||
String? artistId,
|
||||
String? albumId,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -494,11 +497,12 @@ 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,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -746,48 +750,113 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? authorId,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
// Check if library filtering is enabled
|
||||
final allAudiobooks = <Audiobook>[];
|
||||
final seenIds = <String>{};
|
||||
|
||||
// Check if library filtering is enabled (for ABS providers)
|
||||
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
|
||||
|
||||
// If specific libraries are enabled, use browse API for filtering
|
||||
// Extract ABS provider IDs from library paths
|
||||
final absProviderIds = <String>{};
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
|
||||
// Otherwise use the faster MA API (returns all audiobooks)
|
||||
final args = <String, dynamic>{
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (authorId != null) 'author_id': authorId,
|
||||
};
|
||||
|
||||
final response = await _sendCommand(
|
||||
'music/audiobooks/library_items',
|
||||
args: args,
|
||||
);
|
||||
|
||||
// Check for error in response
|
||||
if (response.containsKey('error_code')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
final audiobooks = <Audiobook>[];
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
try {
|
||||
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
||||
audiobooks.add(book);
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
for (final libPath in enabledLibraries) {
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd != -1) {
|
||||
absProviderIds.add(libPath.substring(0, providerEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
return audiobooks;
|
||||
|
||||
_logger.log('📚 getAudiobooks: absProviderIds=$absProviderIds, providerInstanceIds=$providerInstanceIds');
|
||||
|
||||
// PART 1: Fetch from ABS providers using browse API (for library filtering support)
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
// If specific providers are requested, filter libraries to only matching ABS providers
|
||||
var librariesToBrowse = enabledLibraries;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
librariesToBrowse = enabledLibraries.where((libPath) {
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd == -1) return false;
|
||||
final providerId = libPath.substring(0, providerEnd);
|
||||
return providerInstanceIds.contains(providerId);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (librariesToBrowse.isNotEmpty) {
|
||||
final browseBooks = await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
|
||||
for (final book in browseBooks) {
|
||||
if (!seenIds.contains(book.itemId)) {
|
||||
seenIds.add(book.itemId);
|
||||
allAudiobooks.add(book);
|
||||
}
|
||||
}
|
||||
_logger.log('📚 Got ${browseBooks.length} audiobooks from ABS browse API');
|
||||
}
|
||||
}
|
||||
|
||||
// PART 2: Fetch from non-ABS providers using library_items API
|
||||
// This gets audiobooks from Spotify, Plex, Jellyfin, etc.
|
||||
List<String>? nonAbsProviders;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
// Filter to only non-ABS providers from the requested list
|
||||
nonAbsProviders = providerInstanceIds.where((id) => !absProviderIds.contains(id)).toList();
|
||||
} else if (absProviderIds.isNotEmpty) {
|
||||
// No specific providers requested, but we have ABS - need to also fetch from non-ABS
|
||||
// Pass null to get all, then we'll deduplicate
|
||||
nonAbsProviders = null; // Will fetch all
|
||||
}
|
||||
|
||||
// Only call library_items API if:
|
||||
// 1. No ABS libraries configured (traditional mode), OR
|
||||
// 2. We have non-ABS providers to query
|
||||
final shouldFetchFromApi = enabledLibraries == null ||
|
||||
enabledLibraries.isEmpty ||
|
||||
(nonAbsProviders == null && absProviderIds.isNotEmpty) ||
|
||||
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
|
||||
|
||||
if (shouldFetchFromApi) {
|
||||
final args = <String, dynamic>{
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (authorId != null) 'author_id': authorId,
|
||||
if (nonAbsProviders != null && nonAbsProviders.isNotEmpty)
|
||||
'provider': nonAbsProviders,
|
||||
};
|
||||
|
||||
final response = await _sendCommand(
|
||||
'music/audiobooks/library_items',
|
||||
args: args,
|
||||
);
|
||||
|
||||
if (!response.containsKey('error_code')) {
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items != null) {
|
||||
int addedCount = 0;
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
try {
|
||||
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
||||
// Deduplicate by itemId
|
||||
if (!seenIds.contains(book.itemId)) {
|
||||
seenIds.add(book.itemId);
|
||||
allAudiobooks.add(book);
|
||||
addedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
}
|
||||
}
|
||||
_logger.log('📚 Got $addedCount audiobooks from library_items API');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('📚 Total audiobooks: ${allAudiobooks.length}');
|
||||
return allAudiobooks;
|
||||
} catch (e, stack) {
|
||||
_logger.log('Error getting audiobooks: $e');
|
||||
_logger.log('Stack: $stack');
|
||||
@@ -1365,14 +1434,18 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Get random albums
|
||||
Future<List<Album>> getRandomAlbums({int limit = 10, String? provider}) async {
|
||||
Future<List<Album>> getRandomAlbums({
|
||||
int limit = 10,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
args: {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
if (provider != null) 'provider': provider,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1489,6 +1562,7 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -1499,6 +1573,8 @@ class MusicAssistantAPI {
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
|
||||
'provider': providerInstanceIds,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3299,26 +3375,6 @@ class MusicAssistantAPI {
|
||||
_logger.log('⚠️ Could not load provider manifests: $e');
|
||||
}
|
||||
|
||||
// Fetch provider instances (active providers) and extract music providers
|
||||
try {
|
||||
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');
|
||||
}
|
||||
|
||||
_logger.log('✅ Initial state fetch complete');
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error fetching state: $e');
|
||||
@@ -3326,6 +3382,39 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all music provider instances configured in Music Assistant
|
||||
/// Returns only music providers (spotify, tidal, filesystem, etc.) not player/metadata providers
|
||||
Future<List<ProviderInstance>> getMusicProviders() async {
|
||||
try {
|
||||
final response = await _sendCommand('providers');
|
||||
|
||||
final result = response['result'];
|
||||
if (result == null) {
|
||||
_logger.log('⚠️ providers command returned no result');
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> providers = result is List ? result : [];
|
||||
final musicProviders = <ProviderInstance>[];
|
||||
|
||||
for (final providerData in providers) {
|
||||
if (providerData is Map<String, dynamic>) {
|
||||
final instance = ProviderInstance.fromJson(providerData);
|
||||
// Only include music providers (not player providers, metadata providers, etc.)
|
||||
if (instance.isMusicProvider) {
|
||||
musicProviders.add(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('🎵 Found ${musicProviders.length} music providers: ${musicProviders.map((p) => p.name).join(", ")}');
|
||||
return musicProviders;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error getting music providers: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current authenticated user's profile
|
||||
/// Returns user info including display_name, username, groups, etc.
|
||||
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
|
||||
|
||||
@@ -161,6 +161,10 @@ class SettingsService {
|
||||
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
|
||||
static const String _keyDiscoveredAbsLibraries = 'discovered_abs_libraries'; // JSON list of {path, name}
|
||||
|
||||
// Music Provider Filter Settings
|
||||
static const String _keyEnabledMusicProviders = 'enabled_music_providers'; // JSON list of provider instance IDs
|
||||
static const String _keyDiscoveredMusicProviders = 'discovered_music_providers'; // JSON list of {instanceId, domain, name}
|
||||
|
||||
// Hint System Settings
|
||||
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
|
||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||
@@ -172,9 +176,6 @@ 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);
|
||||
@@ -792,6 +793,81 @@ class SettingsService {
|
||||
await setEnabledAbsLibraries(enabled);
|
||||
}
|
||||
|
||||
// Music Provider Filter Settings
|
||||
|
||||
/// Get list of enabled music provider instance IDs (null = all enabled)
|
||||
/// Returns null if no filter is set (all providers enabled by default)
|
||||
static Future<List<String>?> getEnabledMusicProviders() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyEnabledMusicProviders);
|
||||
if (json == null) return null; // null means all providers are enabled
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list.cast<String>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set list of enabled music provider instance IDs
|
||||
/// Pass null or call clearEnabledMusicProviders() to enable all providers
|
||||
static Future<void> setEnabledMusicProviders(List<String> providerIds) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyEnabledMusicProviders, jsonEncode(providerIds));
|
||||
}
|
||||
|
||||
/// Clear enabled providers (reverts to all enabled)
|
||||
static Future<void> clearEnabledMusicProviders() async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyEnabledMusicProviders);
|
||||
}
|
||||
|
||||
/// Get discovered music providers [{instanceId, domain, name}]
|
||||
static Future<List<Map<String, String>>?> getDiscoveredMusicProviders() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyDiscoveredMusicProviders);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list.map((e) => Map<String, String>.from(e as Map)).toList();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save discovered music providers
|
||||
static Future<void> setDiscoveredMusicProviders(List<Map<String, String>> providers) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyDiscoveredMusicProviders, jsonEncode(providers));
|
||||
}
|
||||
|
||||
/// Check if a specific music provider is enabled
|
||||
static Future<bool> isMusicProviderEnabled(String instanceId) async {
|
||||
final enabled = await getEnabledMusicProviders();
|
||||
if (enabled == null) return true; // All enabled by default
|
||||
return enabled.contains(instanceId);
|
||||
}
|
||||
|
||||
/// Toggle a specific music provider
|
||||
/// allProviderIds should contain all available provider instance IDs
|
||||
static Future<void> toggleMusicProvider(String instanceId, bool enable, List<String> allProviderIds) async {
|
||||
var enabled = await getEnabledMusicProviders();
|
||||
// If null (all enabled), initialize with all provider IDs
|
||||
enabled ??= List.from(allProviderIds);
|
||||
|
||||
if (enable && !enabled.contains(instanceId)) {
|
||||
enabled.add(instanceId);
|
||||
} else if (!enable && enabled.contains(instanceId)) {
|
||||
// Prevent disabling the last provider
|
||||
if (enabled.length <= 1) {
|
||||
return; // Don't allow disabling the last provider
|
||||
}
|
||||
enabled.remove(instanceId);
|
||||
}
|
||||
|
||||
await setEnabledMusicProviders(enabled);
|
||||
}
|
||||
|
||||
// Hint System Settings
|
||||
|
||||
/// Get whether hints are enabled (default: true)
|
||||
@@ -874,48 +950,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
+386
-90
@@ -17,6 +17,7 @@ enum SyncStatus {
|
||||
|
||||
/// Service for background library synchronization
|
||||
/// Loads from database cache first, then syncs from MA API in background
|
||||
/// Supports per-provider sync for accurate client-side filtering
|
||||
class SyncService with ChangeNotifier {
|
||||
static SyncService? _instance;
|
||||
static final _logger = DebugLogger();
|
||||
@@ -42,6 +43,13 @@ class SyncService with ChangeNotifier {
|
||||
List<Audiobook> _cachedAudiobooks = [];
|
||||
List<Playlist> _cachedPlaylists = [];
|
||||
|
||||
// Source provider tracking for client-side filtering
|
||||
// Maps itemId -> list of provider instance IDs that provided the item
|
||||
Map<String, List<String>> _albumSourceProviders = {};
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
|
||||
// Getters
|
||||
SyncStatus get status => _status;
|
||||
String? get lastError => _lastError;
|
||||
@@ -54,8 +62,15 @@ class SyncService with ChangeNotifier {
|
||||
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
|
||||
// Source provider getters for client-side filtering
|
||||
Map<String, List<String>> get albumSourceProviders => _albumSourceProviders;
|
||||
Map<String, List<String>> get artistSourceProviders => _artistSourceProviders;
|
||||
Map<String, List<String>> get audiobookSourceProviders => _audiobookSourceProviders;
|
||||
Map<String, List<String>> get playlistSourceProviders => _playlistSourceProviders;
|
||||
|
||||
/// Load library data from database cache (instant)
|
||||
/// Call this on app startup for immediate data
|
||||
/// Also loads source provider info for client-side filtering
|
||||
Future<void> loadFromCache() async {
|
||||
if (!_db.isInitialized) {
|
||||
_logger.log('⚠️ Database not initialized, skipping cache load');
|
||||
@@ -65,52 +80,73 @@ class SyncService with ChangeNotifier {
|
||||
try {
|
||||
_logger.log('📦 Loading library from database cache...');
|
||||
|
||||
// Load albums from cache
|
||||
final albumData = await _db.getCachedItems('album');
|
||||
_cachedAlbums = albumData.map((data) {
|
||||
// Load albums from cache with source providers
|
||||
final albumDataWithProviders = await _db.getCachedItemsWithProviders('album');
|
||||
_cachedAlbums = [];
|
||||
_albumSourceProviders = {};
|
||||
for (final (data, providers) in albumDataWithProviders) {
|
||||
try {
|
||||
return Album.fromJson(data);
|
||||
final album = Album.fromJson(data);
|
||||
_cachedAlbums.add(album);
|
||||
if (providers.isNotEmpty) {
|
||||
_albumSourceProviders[album.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached album: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Album>().toList();
|
||||
}
|
||||
|
||||
// Load artists from cache
|
||||
final artistData = await _db.getCachedItems('artist');
|
||||
_cachedArtists = artistData.map((data) {
|
||||
// Load artists from cache with source providers
|
||||
final artistDataWithProviders = await _db.getCachedItemsWithProviders('artist');
|
||||
_cachedArtists = [];
|
||||
_artistSourceProviders = {};
|
||||
for (final (data, providers) in artistDataWithProviders) {
|
||||
try {
|
||||
return Artist.fromJson(data);
|
||||
final artist = Artist.fromJson(data);
|
||||
_cachedArtists.add(artist);
|
||||
if (providers.isNotEmpty) {
|
||||
_artistSourceProviders[artist.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached artist: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Artist>().toList();
|
||||
}
|
||||
|
||||
// Load audiobooks from cache
|
||||
final audiobookData = await _db.getCachedItems('audiobook');
|
||||
_cachedAudiobooks = audiobookData.map((data) {
|
||||
// Load audiobooks from cache with source providers
|
||||
final audiobookDataWithProviders = await _db.getCachedItemsWithProviders('audiobook');
|
||||
_cachedAudiobooks = [];
|
||||
_audiobookSourceProviders = {};
|
||||
for (final (data, providers) in audiobookDataWithProviders) {
|
||||
try {
|
||||
return Audiobook.fromJson(data);
|
||||
final audiobook = Audiobook.fromJson(data);
|
||||
_cachedAudiobooks.add(audiobook);
|
||||
if (providers.isNotEmpty) {
|
||||
_audiobookSourceProviders[audiobook.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached audiobook: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Audiobook>().toList();
|
||||
}
|
||||
|
||||
// Load playlists from cache
|
||||
final playlistData = await _db.getCachedItems('playlist');
|
||||
_cachedPlaylists = playlistData.map((data) {
|
||||
// Load playlists from cache with source providers
|
||||
final playlistDataWithProviders = await _db.getCachedItemsWithProviders('playlist');
|
||||
_cachedPlaylists = [];
|
||||
_playlistSourceProviders = {};
|
||||
for (final (data, providers) in playlistDataWithProviders) {
|
||||
try {
|
||||
return Playlist.fromJson(data);
|
||||
final playlist = Playlist.fromJson(data);
|
||||
_cachedPlaylists.add(playlist);
|
||||
if (providers.isNotEmpty) {
|
||||
_playlistSourceProviders[playlist.itemId] = providers;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached playlist: $e');
|
||||
return null;
|
||||
}
|
||||
}).whereType<Playlist>().toList();
|
||||
}
|
||||
|
||||
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
|
||||
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
|
||||
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to load from cache: $e');
|
||||
@@ -119,8 +155,12 @@ class SyncService with ChangeNotifier {
|
||||
|
||||
/// Sync library data from MA API in background
|
||||
/// Updates database cache and notifies listeners when complete
|
||||
/// If provider is specified, only fetch items from that provider (server-side filtering)
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false, String? provider}) async {
|
||||
/// [providerInstanceIds] - list of provider IDs to sync (fetches each provider separately for accurate source tracking)
|
||||
Future<void> syncFromApi(
|
||||
MusicAssistantAPI api, {
|
||||
bool force = false,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
if (_isSyncing) {
|
||||
_logger.log('🔄 Sync already in progress, skipping');
|
||||
return;
|
||||
@@ -150,40 +190,146 @@ class SyncService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Starting background library sync (provider=$provider)...');
|
||||
_logger.log('🔄 Starting background library sync...');
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums, provider: $provider');
|
||||
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||
|
||||
// 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, provider: provider),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, provider: provider),
|
||||
api.getAudiobooks(limit: 1000),
|
||||
api.getPlaylists(limit: 1000),
|
||||
]);
|
||||
// Only clear cache for full syncs (no specific providers)
|
||||
// Partial syncs preserve items from disabled providers for instant toggle-on
|
||||
final isPartialSync = providerInstanceIds != null && providerInstanceIds.isNotEmpty;
|
||||
if (!isPartialSync) {
|
||||
await _db.clearCacheForType('album');
|
||||
await _db.clearCacheForType('artist');
|
||||
await _db.clearCacheForType('audiobook');
|
||||
await _db.clearCacheForType('playlist');
|
||||
}
|
||||
|
||||
final albums = results[0] as List<Album>;
|
||||
final artists = results[1] as List<Artist>;
|
||||
final audiobooks = results[2] as List<Audiobook>;
|
||||
final playlists = results[3] as List<Playlist>;
|
||||
// Build NEW source tracking maps - don't modify existing until sync is complete
|
||||
// This prevents UI flicker from partial tracking during sync
|
||||
final syncingProviders = providerInstanceIds?.toSet() ?? <String>{};
|
||||
final newAlbumSourceProviders = <String, List<String>>{};
|
||||
final newArtistSourceProviders = <String, List<String>>{};
|
||||
final newAudiobookSourceProviders = <String, List<String>>{};
|
||||
final newPlaylistSourceProviders = <String, List<String>>{};
|
||||
|
||||
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
|
||||
// For partial syncs, copy tracking from non-syncing providers
|
||||
if (isPartialSync) {
|
||||
for (final entry in _albumSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newAlbumSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
for (final entry in _artistSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newArtistSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
for (final entry in _audiobookSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newAudiobookSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
for (final entry in _playlistSourceProviders.entries) {
|
||||
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
|
||||
if (preserved.isNotEmpty) newPlaylistSourceProviders[entry.key] = preserved;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear old cache before saving fresh data (removes stale items)
|
||||
await _db.clearCacheForType('album');
|
||||
await _db.clearCacheForType('artist');
|
||||
await _db.clearCacheForType('audiobook');
|
||||
await _db.clearCacheForType('playlist');
|
||||
// Collect all items (deduped by itemId, but tracking all source providers)
|
||||
final albumMap = <String, Album>{};
|
||||
final artistMap = <String, Artist>{};
|
||||
final audiobookMap = <String, Audiobook>{};
|
||||
final playlistMap = <String, Playlist>{};
|
||||
|
||||
// Save to database cache
|
||||
await _saveAlbumsToCache(albums);
|
||||
await _saveArtistsToCache(artists);
|
||||
await _saveAudiobooksToCache(audiobooks);
|
||||
await _savePlaylistsToCache(playlists);
|
||||
// If specific providers are requested, sync each separately for accurate source tracking
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
_logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers');
|
||||
|
||||
for (final providerId in providerInstanceIds) {
|
||||
_logger.log(' 📡 Syncing provider: $providerId');
|
||||
|
||||
// Fetch from this specific provider
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
|
||||
api.getAudiobooks(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getPlaylists(limit: 1000, providerInstanceIds: [providerId]),
|
||||
]);
|
||||
|
||||
final albums = results[0] as List<Album>;
|
||||
final artists = results[1] as List<Artist>;
|
||||
final audiobooks = results[2] as List<Audiobook>;
|
||||
final playlists = results[3] as List<Playlist>;
|
||||
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
|
||||
// Add to maps and track source provider in NEW tracking maps
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
newAlbumSourceProviders.putIfAbsent(album.itemId, () => []);
|
||||
if (!newAlbumSourceProviders[album.itemId]!.contains(providerId)) {
|
||||
newAlbumSourceProviders[album.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
for (final artist in artists) {
|
||||
artistMap[artist.itemId] = artist;
|
||||
newArtistSourceProviders.putIfAbsent(artist.itemId, () => []);
|
||||
if (!newArtistSourceProviders[artist.itemId]!.contains(providerId)) {
|
||||
newArtistSourceProviders[artist.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
for (final audiobook in audiobooks) {
|
||||
audiobookMap[audiobook.itemId] = audiobook;
|
||||
newAudiobookSourceProviders.putIfAbsent(audiobook.itemId, () => []);
|
||||
if (!newAudiobookSourceProviders[audiobook.itemId]!.contains(providerId)) {
|
||||
newAudiobookSourceProviders[audiobook.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
for (final playlist in playlists) {
|
||||
playlistMap[playlist.itemId] = playlist;
|
||||
newPlaylistSourceProviders.putIfAbsent(playlist.itemId, () => []);
|
||||
if (!newPlaylistSourceProviders[playlist.itemId]!.contains(providerId)) {
|
||||
newPlaylistSourceProviders[playlist.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No provider filter - fetch all at once (faster, but no source tracking)
|
||||
_logger.log('📡 Fetching from all providers (no source tracking)');
|
||||
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
api.getAudiobooks(limit: 1000),
|
||||
api.getPlaylists(limit: 1000),
|
||||
]);
|
||||
|
||||
for (final album in results[0] as List<Album>) {
|
||||
albumMap[album.itemId] = album;
|
||||
}
|
||||
for (final artist in results[1] as List<Artist>) {
|
||||
artistMap[artist.itemId] = artist;
|
||||
}
|
||||
for (final audiobook in results[2] as List<Audiobook>) {
|
||||
audiobookMap[audiobook.itemId] = audiobook;
|
||||
}
|
||||
for (final playlist in results[3] as List<Playlist>) {
|
||||
playlistMap[playlist.itemId] = playlist;
|
||||
}
|
||||
}
|
||||
|
||||
final albums = albumMap.values.toList();
|
||||
final artists = artistMap.values.toList();
|
||||
final audiobooks = audiobookMap.values.toList();
|
||||
final playlists = playlistMap.values.toList();
|
||||
|
||||
_logger.log('📥 Total: ${albums.length} albums, ${artists.length} artists, '
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists');
|
||||
|
||||
// Save to database cache with source provider info (using NEW tracking maps)
|
||||
await _saveAlbumsToCache(albums, newAlbumSourceProviders);
|
||||
await _saveArtistsToCache(artists, newArtistSourceProviders);
|
||||
await _saveAudiobooksToCache(audiobooks, newAudiobookSourceProviders);
|
||||
await _savePlaylistsToCache(playlists, newPlaylistSourceProviders);
|
||||
|
||||
// Update sync metadata
|
||||
await _db.updateSyncMetadata('albums', albums.length);
|
||||
@@ -192,14 +338,49 @@ class SyncService with ChangeNotifier {
|
||||
await _db.updateSyncMetadata('playlists', playlists.length);
|
||||
|
||||
// Update in-memory cache
|
||||
_cachedAlbums = albums;
|
||||
_cachedArtists = artists;
|
||||
_cachedAudiobooks = audiobooks;
|
||||
_cachedPlaylists = playlists;
|
||||
if (isPartialSync) {
|
||||
// Partial sync: merge with existing cache to preserve items from disabled providers
|
||||
final albumIds = albums.map((a) => a.itemId).toSet();
|
||||
final artistIds = artists.map((a) => a.itemId).toSet();
|
||||
final audiobookIds = audiobooks.map((a) => a.itemId).toSet();
|
||||
final playlistIds = playlists.map((p) => p.itemId).toSet();
|
||||
|
||||
// Keep items not in current sync, add/update items from sync
|
||||
_cachedAlbums = [
|
||||
..._cachedAlbums.where((a) => !albumIds.contains(a.itemId)),
|
||||
...albums,
|
||||
];
|
||||
_cachedArtists = [
|
||||
..._cachedArtists.where((a) => !artistIds.contains(a.itemId)),
|
||||
...artists,
|
||||
];
|
||||
_cachedAudiobooks = [
|
||||
..._cachedAudiobooks.where((a) => !audiobookIds.contains(a.itemId)),
|
||||
...audiobooks,
|
||||
];
|
||||
_cachedPlaylists = [
|
||||
..._cachedPlaylists.where((p) => !playlistIds.contains(p.itemId)),
|
||||
...playlists,
|
||||
];
|
||||
} else {
|
||||
// Full sync: replace entire cache
|
||||
_cachedAlbums = albums;
|
||||
_cachedArtists = artists;
|
||||
_cachedAudiobooks = audiobooks;
|
||||
_cachedPlaylists = playlists;
|
||||
}
|
||||
// Atomically swap in the new source tracking maps
|
||||
// This ensures filtering always sees complete data, never partial
|
||||
_albumSourceProviders = newAlbumSourceProviders;
|
||||
_artistSourceProviders = newArtistSourceProviders;
|
||||
_audiobookSourceProviders = newAudiobookSourceProviders;
|
||||
_playlistSourceProviders = newPlaylistSourceProviders;
|
||||
|
||||
_lastSyncTime = DateTime.now();
|
||||
_status = SyncStatus.completed;
|
||||
|
||||
_logger.log('✅ Library sync complete');
|
||||
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
|
||||
} catch (e) {
|
||||
_logger.log('❌ Library sync failed: $e');
|
||||
_status = SyncStatus.error;
|
||||
@@ -210,15 +391,28 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save albums to database cache
|
||||
Future<void> _saveAlbumsToCache(List<Album> albums) async {
|
||||
/// Save albums to database cache with source provider tracking
|
||||
Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
|
||||
for (final album in albums) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: album.toJson(),
|
||||
);
|
||||
final providers = sourceProviders[album.itemId];
|
||||
// Save with first source provider (merging happens in DB layer)
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: album.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
// If no source providers, still cache the item
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'album',
|
||||
itemId: album.itemId,
|
||||
data: album.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
|
||||
}
|
||||
@@ -226,15 +420,26 @@ class SyncService with ChangeNotifier {
|
||||
_logger.log('💾 Saved ${albums.length} albums to cache');
|
||||
}
|
||||
|
||||
/// Save artists to database cache
|
||||
Future<void> _saveArtistsToCache(List<Artist> artists) async {
|
||||
/// Save artists to database cache with source provider tracking
|
||||
Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
|
||||
for (final artist in artists) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: artist.toJson(),
|
||||
);
|
||||
final providers = sourceProviders[artist.itemId];
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: artist.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'artist',
|
||||
itemId: artist.itemId,
|
||||
data: artist.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
|
||||
}
|
||||
@@ -242,15 +447,26 @@ class SyncService with ChangeNotifier {
|
||||
_logger.log('💾 Saved ${artists.length} artists to cache');
|
||||
}
|
||||
|
||||
/// Save audiobooks to database cache
|
||||
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks) async {
|
||||
/// Save audiobooks to database cache with source provider tracking
|
||||
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
|
||||
for (final audiobook in audiobooks) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: audiobook.toJson(),
|
||||
);
|
||||
final providers = sourceProviders[audiobook.itemId];
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: audiobook.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'audiobook',
|
||||
itemId: audiobook.itemId,
|
||||
data: audiobook.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
|
||||
}
|
||||
@@ -258,15 +474,26 @@ class SyncService with ChangeNotifier {
|
||||
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
|
||||
}
|
||||
|
||||
/// Save playlists to database cache
|
||||
Future<void> _savePlaylistsToCache(List<Playlist> playlists) async {
|
||||
/// Save playlists to database cache with source provider tracking
|
||||
Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
|
||||
for (final playlist in playlists) {
|
||||
try {
|
||||
await _db.cacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: playlist.toJson(),
|
||||
);
|
||||
final providers = sourceProviders[playlist.itemId];
|
||||
for (final provider in providers ?? <String>[]) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: playlist.toJson(),
|
||||
sourceProvider: provider,
|
||||
);
|
||||
}
|
||||
if (providers == null || providers.isEmpty) {
|
||||
await _db.cacheItem(
|
||||
itemType: 'playlist',
|
||||
itemId: playlist.itemId,
|
||||
data: playlist.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
|
||||
}
|
||||
@@ -275,12 +502,12 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Force a fresh sync (for pull-to-refresh)
|
||||
Future<void> forceSync(MusicAssistantAPI api, {String? provider}) async {
|
||||
await syncFromApi(api, force: true, provider: provider);
|
||||
/// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
|
||||
Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
|
||||
await syncFromApi(api, force: true, providerInstanceIds: providerInstanceIds);
|
||||
}
|
||||
|
||||
/// Clear all cached data and reset sync state
|
||||
/// This allows a fresh sync to start even if one was in progress
|
||||
/// Clear all cached data
|
||||
Future<void> clearCache() async {
|
||||
if (!_db.isInitialized) return;
|
||||
|
||||
@@ -289,13 +516,82 @@ class SyncService with ChangeNotifier {
|
||||
_cachedArtists = [];
|
||||
_cachedAudiobooks = [];
|
||||
_cachedPlaylists = [];
|
||||
_albumSourceProviders = {};
|
||||
_artistSourceProviders = {};
|
||||
_audiobookSourceProviders = {};
|
||||
_playlistSourceProviders = {};
|
||||
_lastSyncTime = null;
|
||||
_isSyncing = false; // Reset so a new sync can start
|
||||
_status = SyncStatus.idle;
|
||||
notifyListeners();
|
||||
_logger.log('🗑️ Library cache cleared (sync state reset)');
|
||||
_logger.log('🗑️ Library cache cleared');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Client-side filtering methods
|
||||
// ============================================
|
||||
|
||||
/// Filter albums by source provider (instant, no network)
|
||||
/// Empty enabledProviderIds = all providers enabled, show everything
|
||||
/// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
// Empty set = all providers enabled, show everything
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
// STRICT MODE: Hide items without tracking to ensure accurate filtering
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Filter artists by source provider (instant, no network)
|
||||
List<Artist> getArtistsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedArtists;
|
||||
}
|
||||
return _cachedArtists.where((artist) {
|
||||
final sources = _artistSourceProviders[artist.itemId];
|
||||
// STRICT MODE: Hide items without tracking
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Filter audiobooks by source provider (instant, no network)
|
||||
List<Audiobook> getAudiobooksFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedAudiobooks;
|
||||
}
|
||||
return _cachedAudiobooks.where((audiobook) {
|
||||
final sources = _audiobookSourceProviders[audiobook.itemId];
|
||||
// STRICT MODE: Hide items without tracking
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Filter playlists by source provider (instant, no network)
|
||||
List<Playlist> getPlaylistsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty) {
|
||||
return _cachedPlaylists;
|
||||
}
|
||||
return _cachedPlaylists.where((playlist) {
|
||||
final sources = _playlistSourceProviders[playlist.itemId];
|
||||
// STRICT MODE: Hide items without tracking
|
||||
if (sources == null || sources.isEmpty) return false;
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Check if we have source provider tracking data
|
||||
bool get hasSourceTracking =>
|
||||
_albumSourceProviders.isNotEmpty ||
|
||||
_artistSourceProviders.isNotEmpty ||
|
||||
_audiobookSourceProviders.isNotEmpty ||
|
||||
_playlistSourceProviders.isNotEmpty;
|
||||
|
||||
/// Get albums (from cache or empty if not loaded)
|
||||
List<Album> getAlbums() => _cachedAlbums;
|
||||
|
||||
|
||||
@@ -103,39 +103,48 @@ class EmptyState extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
// Wrap in scrollable to allow PageView horizontal swipe detection
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(actionLabel!),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(actionLabel!),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -162,51 +171,60 @@ class _CustomEmptyState extends EmptyState {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final s = S.of(context)!;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
// Wrap in scrollable to allow PageView horizontal swipe detection
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: Spacing.paddingAll24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurface.withOpacity(0.38),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
Spacing.vGap8,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (onRefresh != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(s.refresh),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
Spacing.vGap8,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (onRefresh != null) ...[
|
||||
Spacing.vGap24,
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(s.refresh),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../services/debug_logger.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
|
||||
@@ -72,9 +73,9 @@ class DebugConsole extends StatelessWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(40, 20),
|
||||
),
|
||||
child: const Text(
|
||||
'Clear',
|
||||
style: TextStyle(fontSize: 10, color: Colors.green),
|
||||
child: Text(
|
||||
S.of(context)!.clear,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.green),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -86,17 +87,17 @@ class DebugConsole extends StatelessWidget {
|
||||
if (logs.isNotEmpty) {
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Logs copied to clipboard'),
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.logsCopied),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No logs to copy'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.noLogsToCopy),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,17 @@ import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../services/animation_debugger.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../screens/artist_details_screen.dart';
|
||||
import '../screens/album_details_screen.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'animated_icon_button.dart';
|
||||
import 'global_player_overlay.dart';
|
||||
import 'volume_control.dart';
|
||||
@@ -508,7 +513,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
// Only refresh if the event is for our current player
|
||||
if (playerId != null && selectedPlayer != null && playerId == selectedPlayer.playerId) {
|
||||
debugPrint('QueuePanel: Received queue_items_updated event, refreshing queue');
|
||||
_loadQueue();
|
||||
}
|
||||
});
|
||||
@@ -527,7 +531,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Clear queue if player changed (e.g., after queue transfer)
|
||||
if (_queuePlayerId != null && _queuePlayerId != player.playerId) {
|
||||
debugPrint('🔀 Player changed from $_queuePlayerId to ${player.playerId}, clearing queue');
|
||||
_queue = null;
|
||||
_queuePlayerId = player.playerId;
|
||||
}
|
||||
@@ -552,7 +555,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
try {
|
||||
final freshQueue = await maProvider.getQueue(player.playerId);
|
||||
if (mounted && freshQueue != null) {
|
||||
debugPrint('🔀 Queue loaded: shuffle=${freshQueue.shuffle}, shuffleEnabled=${freshQueue.shuffleEnabled}');
|
||||
// Check if queue items were reordered by comparing first few item IDs
|
||||
bool itemsReordered = false;
|
||||
if (_queue != null && _queue!.items.isNotEmpty && freshQueue.items.isNotEmpty) {
|
||||
@@ -561,7 +563,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
for (int i = 0; i < maxCheck; i++) {
|
||||
if (_queue!.items[i].queueItemId != freshQueue.items[i].queueItemId) {
|
||||
itemsReordered = true;
|
||||
debugPrint('🔀 Queue items reordered detected at position $i');
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -573,7 +574,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_queue!.shuffle != freshQueue.shuffle ||
|
||||
itemsReordered;
|
||||
if (queueChanged) {
|
||||
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
|
||||
setState(() {
|
||||
_queue = freshQueue;
|
||||
_queuePlayerId = player.playerId;
|
||||
@@ -616,13 +616,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
|
||||
Future<void> _toggleShuffle() async {
|
||||
debugPrint('🔀 Shuffle button pressed');
|
||||
if (_queue == null) {
|
||||
debugPrint('🔀 Shuffle: _queue is null, returning');
|
||||
return;
|
||||
}
|
||||
if (_queue == null) return;
|
||||
final newShuffleState = !_queue!.shuffle;
|
||||
debugPrint('🔀 Shuffle: current=${_queue!.shuffle}, toggling to $newShuffleState');
|
||||
|
||||
// Optimistically update local state for immediate visual feedback
|
||||
setState(() {
|
||||
@@ -634,15 +629,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
repeatMode: _queue!.repeatMode,
|
||||
);
|
||||
});
|
||||
debugPrint('🔀 Shuffle: optimistic update applied, shuffleEnabled=$newShuffleState');
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
// Toggle: if currently shuffled, disable; if not shuffled, enable
|
||||
await maProvider.toggleShuffle(_queue!.playerId, newShuffleState);
|
||||
debugPrint('🔀 Shuffle: command sent, waiting for server to reorder queue');
|
||||
// Small delay to allow server to finish reordering queue items
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
debugPrint('🔀 Shuffle: reloading queue');
|
||||
await _loadQueue();
|
||||
}
|
||||
|
||||
@@ -894,6 +886,45 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to artist details screen
|
||||
void _navigateToArtist(MusicAssistantProvider maProvider, dynamic currentTrack) {
|
||||
if (currentTrack == null) return;
|
||||
|
||||
// Skip for podcasts/audiobooks - they show author/podcast name but navigation isn't applicable
|
||||
if (maProvider.isPlayingPodcast || maProvider.isPlayingAudiobook) return;
|
||||
|
||||
// For regular tracks, navigate to first artist
|
||||
final artists = currentTrack.artists;
|
||||
if (artists != null && artists is List && artists.isNotEmpty) {
|
||||
final artist = artists.first;
|
||||
if (artist is Artist) {
|
||||
// Collapse player first, then navigate using global navigator key
|
||||
collapse();
|
||||
navigationProvider.navigatorKey.currentState?.push(
|
||||
FadeSlidePageRoute(
|
||||
child: ArtistDetailsScreen(artist: artist),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to album details screen
|
||||
void _navigateToAlbum(dynamic currentTrack) {
|
||||
if (currentTrack == null) return;
|
||||
|
||||
final album = currentTrack.album;
|
||||
if (album != null && album is Album) {
|
||||
// Collapse player first, then navigate using global navigator key
|
||||
collapse();
|
||||
navigationProvider.navigatorKey.currentState?.push(
|
||||
FadeSlidePageRoute(
|
||||
child: AlbumDetailsScreen(album: album),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show fullscreen album art overlay
|
||||
void _showFullscreenArt(BuildContext context, String? imageUrl) {
|
||||
if (imageUrl == null) return;
|
||||
@@ -2126,21 +2157,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
child: Align(
|
||||
// PERF Phase 5: Use pre-computed alignment
|
||||
alignment: textAlignment,
|
||||
child: Text(
|
||||
// Always show artist/author/podcast name (was showing "Now Playing" when device reveal visible)
|
||||
maProvider.isPlayingAudiobook
|
||||
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
||||
: maProvider.isPlayingPodcast
|
||||
? (maProvider.currentPodcastName ?? S.of(context)!.podcasts)
|
||||
: currentTrack.artistsString,
|
||||
style: TextStyle(
|
||||
// PERF Phase 4: Use Color.lerp between pre-computed colors
|
||||
color: Color.lerp(textColor60, textColor70, t),
|
||||
fontSize: artistFontSize,
|
||||
child: GestureDetector(
|
||||
onTap: t > 0.8 ? () => _navigateToArtist(maProvider, currentTrack) : null,
|
||||
child: Text(
|
||||
// Always show artist/author/podcast name (was showing "Now Playing" when device reveal visible)
|
||||
maProvider.isPlayingAudiobook
|
||||
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
||||
: maProvider.isPlayingPodcast
|
||||
? (maProvider.currentPodcastName ?? S.of(context)!.podcasts)
|
||||
: currentTrack.artistsString,
|
||||
style: TextStyle(
|
||||
// PERF Phase 4: Use Color.lerp between pre-computed colors
|
||||
color: Color.lerp(textColor60, textColor70, t),
|
||||
fontSize: artistFontSize,
|
||||
),
|
||||
textAlign: TextAlign.left, // Keep static, Align handles centering
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.left, // Keep static, Align handles centering
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2189,16 +2223,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
|
||||
child: maProvider.isPlayingAudiobook
|
||||
? _buildChapterInfo(maProvider, textColor, t)
|
||||
: Text(
|
||||
currentTrack.album!.name,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w300,
|
||||
: GestureDetector(
|
||||
onTap: t > 0.8 ? () => _navigateToAlbum(currentTrack) : null,
|
||||
child: Text(
|
||||
currentTrack.album!.name,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/player.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
@@ -162,9 +163,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
if (playerId != null) {
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
||||
debugPrint('QueuePanel: Delete successful for ${item.track.name}');
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error deleting queue item: $e');
|
||||
// Rollback: re-insert the item or refresh from server
|
||||
// Refresh is safer as queue may have changed
|
||||
widget.onRefresh();
|
||||
@@ -185,7 +184,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandClear(playerId);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error clearing queue: $e');
|
||||
// Refresh to restore if failed
|
||||
widget.onRefresh();
|
||||
}
|
||||
@@ -257,7 +255,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Transfer to...',
|
||||
S.of(context)!.transferTo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -271,7 +269,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Text(
|
||||
'No other players available',
|
||||
S.of(context)!.noOtherPlayersAvailable,
|
||||
style: TextStyle(color: menuTextColor.withOpacity(0.5), fontSize: 14),
|
||||
),
|
||||
)
|
||||
@@ -346,7 +344,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
autoPlay: true,
|
||||
);
|
||||
|
||||
debugPrint('QueuePanel: Queue transferred to ${targetPlayer.name}');
|
||||
|
||||
// Clear source player's track cache since its queue was transferred away
|
||||
widget.maProvider.clearPlayerTrackCache(sourcePlayerId);
|
||||
@@ -357,7 +354,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
// Switch to the target player - mini player will update to show new player
|
||||
widget.maProvider.selectPlayer(targetPlayer);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error transferring queue: $e');
|
||||
// Error transferring queue - handled silently
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,11 +520,9 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
final playerId = widget.queue?.playerId;
|
||||
// API uses relative shift: positive = down, negative = up
|
||||
final posShift = newIndex - originalIndex;
|
||||
debugPrint('QueuePanel: Moving ${item.track.name} from $originalIndex to $newIndex (shift: $posShift, queueItemId: ${item.queueItemId})');
|
||||
if (playerId != null) {
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
||||
debugPrint('QueuePanel: Move API call completed successfully');
|
||||
// Allow updates again after a delay for server state to propagate
|
||||
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
||||
if (mounted) {
|
||||
@@ -537,7 +532,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Move API error: $e');
|
||||
// Clear pending state immediately on error
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -548,7 +542,6 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
widget.onRefresh();
|
||||
}
|
||||
} else {
|
||||
debugPrint('QueuePanel: playerId is null, cannot move');
|
||||
// Clear pending state
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -595,7 +588,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error playing index: $e');
|
||||
// Error playing index - handled silently
|
||||
}
|
||||
|
||||
// Clear optimistic state after delay - server state will take over
|
||||
@@ -720,14 +713,14 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
),
|
||||
onPressed: () => _handleTransferQueue(context),
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: 'Transfer queue to another player',
|
||||
tooltip: S.of(context)!.transferQueueToPlayer,
|
||||
),
|
||||
// Clear queue button
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: _handleClearQueue,
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: 'Clear queue',
|
||||
tooltip: S.of(context)!.clearQueue,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -245,14 +245,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
// Show snackbar for visual feedback
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Syncing ${player.name}...'),
|
||||
content: Text(S.of(context)!.syncingPlayer(player.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Long-press to sync/unsync player
|
||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.8.8-beta+46
|
||||
version: 2.8.9-beta+47
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user