Guard _updatePlayerState() polling against disconnected state to prevent "Not authenticated" log spam. Cache last-known track per player so it survives idle-out and can be re-queued when user taps play. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
848 lines
33 KiB
Dart
848 lines
33 KiB
Dart
import 'dart:convert';
|
|
import '../models/media_item.dart';
|
|
import '../models/player.dart';
|
|
import '../models/recommendation_folder.dart';
|
|
import '../services/debug_logger.dart';
|
|
import '../services/database_service.dart';
|
|
import '../constants/timings.dart';
|
|
|
|
/// Centralized cache service for all app data caching
|
|
/// This is a non-notifying service - it just stores and retrieves cached data
|
|
class CacheService {
|
|
final DebugLogger _logger = DebugLogger();
|
|
bool _homeRowsLoaded = false;
|
|
Future<void>? _homeRowsLoadFuture;
|
|
|
|
// Cache size limits to prevent unbounded memory growth
|
|
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
|
|
static const int _maxSearchCacheSize = 20; // Max search queries cached
|
|
static const int _maxPlayerTrackCacheSize = 30; // Max player track associations
|
|
|
|
// Home screen row caching
|
|
List<Album>? _cachedRecentAlbums;
|
|
List<Artist>? _cachedDiscoverArtists;
|
|
List<Album>? _cachedDiscoverAlbums;
|
|
List<RecommendationFolder>? _cachedDiscoveryFolders;
|
|
List<Audiobook>? _cachedInProgressAudiobooks;
|
|
List<Audiobook>? _cachedDiscoverAudiobooks;
|
|
List<AudiobookSeries>? _cachedDiscoverSeries;
|
|
DateTime? _recentAlbumsLastFetched;
|
|
DateTime? _discoverArtistsLastFetched;
|
|
DateTime? _discoverAlbumsLastFetched;
|
|
DateTime? _discoveryFoldersLastFetched;
|
|
DateTime? _inProgressAudiobooksLastFetched;
|
|
DateTime? _discoverAudiobooksLastFetched;
|
|
DateTime? _discoverSeriesLastFetched;
|
|
|
|
// Detail screen caching
|
|
final Map<String, List<Track>> _albumTracksCache = {};
|
|
final Map<String, DateTime> _albumTracksCacheTime = {};
|
|
final Map<String, List<Track>> _playlistTracksCache = {};
|
|
final Map<String, DateTime> _playlistTracksCacheTime = {};
|
|
final Map<String, List<Album>> _artistAlbumsCache = {};
|
|
final Map<String, DateTime> _artistAlbumsCacheTime = {};
|
|
|
|
// Search results caching
|
|
final Map<String, Map<String, List<MediaItem>>> _searchCache = {};
|
|
final Map<String, DateTime> _searchCacheTime = {};
|
|
|
|
// Player list caching
|
|
List<Player>? _cachedPlayers;
|
|
Player? _cachedSelectedPlayer;
|
|
DateTime? _playersLastFetched;
|
|
|
|
// Player track cache (for smooth swipe transitions)
|
|
final Map<String, Track?> _playerTrackCache = {};
|
|
final Map<String, DateTime> _playerTrackCacheTime = {};
|
|
|
|
// Last-known track cache (survives idle-out, for resume after Chromecast idle)
|
|
final Map<String, Track> _lastKnownTrackCache = {};
|
|
|
|
// ============================================================================
|
|
// HOME SCREEN ROW CACHING
|
|
// ============================================================================
|
|
|
|
/// Ensure home rows are loaded from database before accessing cache
|
|
/// This ensures the cache is populated on first access if not already loaded
|
|
Future<void> ensureHomeRowsLoaded() async {
|
|
if (!_homeRowsLoaded) {
|
|
await loadHomeRowsFromDatabase();
|
|
}
|
|
}
|
|
|
|
/// Check if recent albums cache is valid
|
|
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _cachedRecentAlbums != null &&
|
|
_recentAlbumsLastFetched != null &&
|
|
now.difference(_recentAlbumsLastFetched!) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached recent albums
|
|
List<Album>? getCachedRecentAlbums() => _cachedRecentAlbums;
|
|
|
|
/// Set cached recent albums
|
|
void setCachedRecentAlbums(List<Album> albums) {
|
|
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
|
if (albums.isEmpty) {
|
|
_logger.log('⚠️ Not caching empty recent albums result');
|
|
return;
|
|
}
|
|
_cachedRecentAlbums = albums;
|
|
_recentAlbumsLastFetched = DateTime.now();
|
|
_logger.log('✅ Cached ${albums.length} recent albums');
|
|
// Persist to database for instant load on next launch
|
|
_persistHomeRowToDatabase('recent_albums', albums.map((a) => a.toJson()).toList());
|
|
}
|
|
|
|
/// Check if discover artists cache is valid
|
|
bool isDiscoverArtistsCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _cachedDiscoverArtists != null &&
|
|
_discoverArtistsLastFetched != null &&
|
|
now.difference(_discoverArtistsLastFetched!) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached discover artists
|
|
List<Artist>? getCachedDiscoverArtists() => _cachedDiscoverArtists;
|
|
|
|
/// Set cached discover artists
|
|
void setCachedDiscoverArtists(List<Artist> artists) {
|
|
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
|
if (artists.isEmpty) {
|
|
_logger.log('⚠️ Not caching empty discover artists result');
|
|
return;
|
|
}
|
|
_cachedDiscoverArtists = artists;
|
|
_discoverArtistsLastFetched = DateTime.now();
|
|
_logger.log('✅ Cached ${artists.length} discover artists');
|
|
// Persist to database for instant load on next launch
|
|
_persistHomeRowToDatabase('discover_artists', artists.map((a) => a.toJson()).toList());
|
|
}
|
|
|
|
/// Check if discover albums cache is valid
|
|
bool isDiscoverAlbumsCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _cachedDiscoverAlbums != null &&
|
|
_discoverAlbumsLastFetched != null &&
|
|
now.difference(_discoverAlbumsLastFetched!) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached discover albums
|
|
List<Album>? getCachedDiscoverAlbums() => _cachedDiscoverAlbums;
|
|
|
|
/// Set cached discover albums
|
|
void setCachedDiscoverAlbums(List<Album> albums) {
|
|
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
|
if (albums.isEmpty) {
|
|
_logger.log('⚠️ Not caching empty discover albums result');
|
|
return;
|
|
}
|
|
_cachedDiscoverAlbums = albums;
|
|
_discoverAlbumsLastFetched = DateTime.now();
|
|
_logger.log('✅ Cached ${albums.length} discover albums');
|
|
// Persist to database for instant load on next launch
|
|
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
|
}
|
|
|
|
/// Check if discovery folders cache is valid
|
|
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _cachedDiscoveryFolders != null &&
|
|
_discoveryFoldersLastFetched != null &&
|
|
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached discovery folders
|
|
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
|
|
|
|
/// Set cached discovery folders
|
|
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
|
|
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
|
if (folders.isEmpty) {
|
|
_logger.log('⚠️ Not caching empty discovery folders result');
|
|
return;
|
|
}
|
|
_cachedDiscoveryFolders = folders;
|
|
_discoveryFoldersLastFetched = DateTime.now();
|
|
_logger.log('✅ Cached ${folders.length} discovery folders');
|
|
// Persist to database for instant load on next launch
|
|
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
|
|
}
|
|
|
|
/// Check if in-progress audiobooks cache is valid
|
|
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _cachedInProgressAudiobooks != null &&
|
|
_inProgressAudiobooksLastFetched != null &&
|
|
now.difference(_inProgressAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached in-progress audiobooks
|
|
List<Audiobook>? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks;
|
|
|
|
/// Set cached in-progress audiobooks
|
|
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
|
|
// Don't cache empty results
|
|
if (audiobooks.isEmpty) {
|
|
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
|
|
return;
|
|
}
|
|
_cachedInProgressAudiobooks = audiobooks;
|
|
_inProgressAudiobooksLastFetched = DateTime.now();
|
|
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
|
|
}
|
|
|
|
/// Check if discover audiobooks cache is valid
|
|
bool isDiscoverAudiobooksCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _cachedDiscoverAudiobooks != null &&
|
|
_discoverAudiobooksLastFetched != null &&
|
|
now.difference(_discoverAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached discover audiobooks
|
|
List<Audiobook>? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks;
|
|
|
|
/// Set cached discover audiobooks
|
|
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
|
|
// Don't cache empty results
|
|
if (audiobooks.isEmpty) {
|
|
_logger.log('⚠️ Not caching empty discover audiobooks result');
|
|
return;
|
|
}
|
|
_cachedDiscoverAudiobooks = audiobooks;
|
|
_discoverAudiobooksLastFetched = DateTime.now();
|
|
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
|
|
}
|
|
|
|
/// Check if discover series cache is valid
|
|
bool isDiscoverSeriesCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _cachedDiscoverSeries != null &&
|
|
_discoverSeriesLastFetched != null &&
|
|
now.difference(_discoverSeriesLastFetched!) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached discover series
|
|
List<AudiobookSeries>? getCachedDiscoverSeries() => _cachedDiscoverSeries;
|
|
|
|
/// Set cached discover series
|
|
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
|
|
// Don't cache empty results
|
|
if (series.isEmpty) {
|
|
_logger.log('⚠️ Not caching empty discover series result');
|
|
return;
|
|
}
|
|
_cachedDiscoverSeries = series;
|
|
_discoverSeriesLastFetched = DateTime.now();
|
|
_logger.log('✅ Cached ${series.length} discover series');
|
|
}
|
|
|
|
/// Invalidate audiobook caches
|
|
void invalidateAudiobookCaches() {
|
|
_cachedInProgressAudiobooks = null;
|
|
_inProgressAudiobooksLastFetched = null;
|
|
_cachedDiscoverAudiobooks = null;
|
|
_discoverAudiobooksLastFetched = null;
|
|
_cachedDiscoverSeries = null;
|
|
_discoverSeriesLastFetched = null;
|
|
_logger.log('🗑️ Audiobook caches invalidated');
|
|
}
|
|
|
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
|
void invalidateHomeCache() {
|
|
_recentAlbumsLastFetched = null;
|
|
_discoverArtistsLastFetched = null;
|
|
_discoverAlbumsLastFetched = null;
|
|
_discoveryFoldersLastFetched = null;
|
|
_inProgressAudiobooksLastFetched = null;
|
|
_discoverAudiobooksLastFetched = null;
|
|
_discoverSeriesLastFetched = null;
|
|
_logger.log('🗑️ Home screen cache invalidated');
|
|
}
|
|
|
|
/// Invalidate home album caches (call when albums are added/removed from library)
|
|
void invalidateHomeAlbumCaches() {
|
|
_cachedRecentAlbums = null;
|
|
_recentAlbumsLastFetched = null;
|
|
_cachedDiscoverAlbums = null;
|
|
_discoverAlbumsLastFetched = null;
|
|
_logger.log('🗑️ Home album caches invalidated');
|
|
}
|
|
|
|
/// Invalidate home artist caches (call when artists are added/removed from library)
|
|
void invalidateHomeArtistCaches() {
|
|
_cachedDiscoverArtists = null;
|
|
_discoverArtistsLastFetched = null;
|
|
_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
|
|
// ============================================================================
|
|
|
|
/// Check if album tracks cache is valid
|
|
bool isAlbumTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
final cacheTime = _albumTracksCacheTime[cacheKey];
|
|
return _albumTracksCache.containsKey(cacheKey) &&
|
|
cacheTime != null &&
|
|
now.difference(cacheTime) < const Duration(minutes: 5);
|
|
}
|
|
|
|
/// Get cached album tracks
|
|
List<Track>? getCachedAlbumTracks(String cacheKey) => _albumTracksCache[cacheKey];
|
|
|
|
/// Set cached album tracks
|
|
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
|
|
_albumTracksCache[cacheKey] = tracks;
|
|
_albumTracksCacheTime[cacheKey] = DateTime.now();
|
|
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
|
|
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
|
_persistDetailTracks('album', cacheKey, tracks);
|
|
}
|
|
|
|
/// Invalidate album tracks cache for a specific album
|
|
void invalidateAlbumTracksCache(String albumId) {
|
|
_albumTracksCache.remove(albumId);
|
|
_albumTracksCacheTime.remove(albumId);
|
|
}
|
|
|
|
/// Invalidate all album tracks caches (call when tracks are added/removed from library)
|
|
void invalidateAllAlbumTracksCaches() {
|
|
_albumTracksCache.clear();
|
|
_albumTracksCacheTime.clear();
|
|
_logger.log('🗑️ All album tracks caches invalidated');
|
|
}
|
|
|
|
/// Check if playlist tracks cache is valid
|
|
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
final cacheTime = _playlistTracksCacheTime[cacheKey];
|
|
return _playlistTracksCache.containsKey(cacheKey) &&
|
|
cacheTime != null &&
|
|
now.difference(cacheTime) < const Duration(minutes: 5);
|
|
}
|
|
|
|
/// Get cached playlist tracks
|
|
List<Track>? getCachedPlaylistTracks(String cacheKey) => _playlistTracksCache[cacheKey];
|
|
|
|
/// Set cached playlist tracks
|
|
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
|
|
_playlistTracksCache[cacheKey] = tracks;
|
|
_playlistTracksCacheTime[cacheKey] = DateTime.now();
|
|
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
|
|
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
|
_persistDetailTracks('playlist', cacheKey, tracks);
|
|
}
|
|
|
|
/// Invalidate playlist tracks cache for a specific playlist
|
|
void invalidatePlaylistTracksCache(String playlistId) {
|
|
_playlistTracksCache.remove(playlistId);
|
|
_playlistTracksCacheTime.remove(playlistId);
|
|
}
|
|
|
|
/// Invalidate all playlist tracks caches (call when tracks are added/removed from library)
|
|
void invalidateAllPlaylistTracksCaches() {
|
|
_playlistTracksCache.clear();
|
|
_playlistTracksCacheTime.clear();
|
|
_logger.log('🗑️ All playlist tracks caches invalidated');
|
|
}
|
|
|
|
/// Check if artist albums cache is valid
|
|
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
final cacheTime = _artistAlbumsCacheTime[cacheKey];
|
|
return _artistAlbumsCache.containsKey(cacheKey) &&
|
|
cacheTime != null &&
|
|
now.difference(cacheTime) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached artist albums
|
|
List<Album>? getCachedArtistAlbums(String cacheKey) => _artistAlbumsCache[cacheKey];
|
|
|
|
/// Set cached artist albums
|
|
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
|
|
_artistAlbumsCache[cacheKey] = albums;
|
|
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
|
|
_evictOldestEntries(_artistAlbumsCache, _artistAlbumsCacheTime, _maxDetailCacheSize);
|
|
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
|
|
}
|
|
|
|
/// Invalidate all artist albums caches (call when albums are added/removed from library)
|
|
void invalidateArtistAlbumsCache() {
|
|
_artistAlbumsCache.clear();
|
|
_artistAlbumsCacheTime.clear();
|
|
_logger.log('🗑️ Artist albums cache invalidated');
|
|
}
|
|
|
|
// ============================================================================
|
|
// SEARCH CACHING
|
|
// ============================================================================
|
|
|
|
/// Check if search cache is valid
|
|
bool isSearchCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
final cacheTime = _searchCacheTime[cacheKey];
|
|
return _searchCache.containsKey(cacheKey) &&
|
|
cacheTime != null &&
|
|
now.difference(cacheTime) < Timings.homeRowCacheDuration;
|
|
}
|
|
|
|
/// Get cached search results
|
|
Map<String, List<MediaItem>>? getCachedSearchResults(String cacheKey) => _searchCache[cacheKey];
|
|
|
|
/// Set cached search results
|
|
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
|
|
_searchCache[cacheKey] = results;
|
|
_searchCacheTime[cacheKey] = DateTime.now();
|
|
_evictOldestEntries(_searchCache, _searchCacheTime, _maxSearchCacheSize);
|
|
_logger.log('✅ Cached search results for "$cacheKey"');
|
|
}
|
|
|
|
/// Invalidate all search cache (call when library items change)
|
|
void invalidateSearchCache() {
|
|
_searchCache.clear();
|
|
_searchCacheTime.clear();
|
|
_logger.log('🗑️ Search cache invalidated');
|
|
}
|
|
|
|
// ============================================================================
|
|
// PLAYER CACHING
|
|
// ============================================================================
|
|
|
|
/// Check if players cache is valid
|
|
bool isPlayersCacheValid({bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final now = DateTime.now();
|
|
return _playersLastFetched != null &&
|
|
now.difference(_playersLastFetched!) < Timings.playersCacheDuration;
|
|
}
|
|
|
|
/// Update players cache timestamp
|
|
void updatePlayersLastFetched() {
|
|
_playersLastFetched = DateTime.now();
|
|
}
|
|
|
|
/// Get cached players list
|
|
List<Player>? getCachedPlayers() => _cachedPlayers;
|
|
|
|
/// Set cached players list
|
|
void setCachedPlayers(List<Player> players) {
|
|
_cachedPlayers = players;
|
|
_playersLastFetched = DateTime.now();
|
|
}
|
|
|
|
/// Get cached selected player
|
|
Player? getCachedSelectedPlayer() => _cachedSelectedPlayer;
|
|
|
|
/// Set cached selected player
|
|
void setCachedSelectedPlayer(Player? player) {
|
|
_cachedSelectedPlayer = player;
|
|
}
|
|
|
|
/// Check if we have cached players (for instant UI display on app resume)
|
|
bool get hasCachedPlayers => _cachedPlayers != null && _cachedPlayers!.isNotEmpty;
|
|
|
|
/// Get cached track for a player (used for smooth swipe transitions)
|
|
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
|
|
|
|
/// Get all player IDs that have cached tracks
|
|
Iterable<String> getAllCachedPlayerIds() => _playerTrackCache.keys;
|
|
|
|
/// Set cached track for a player
|
|
void setCachedTrackForPlayer(String playerId, Track? track) {
|
|
_playerTrackCache[playerId] = track;
|
|
_playerTrackCacheTime[playerId] = DateTime.now();
|
|
_evictOldestEntries(_playerTrackCache, _playerTrackCacheTime, _maxPlayerTrackCacheSize);
|
|
}
|
|
|
|
/// Clear cached track for a player (e.g., when external source is active)
|
|
void clearCachedTrackForPlayer(String playerId) {
|
|
_playerTrackCache.remove(playerId);
|
|
_playerTrackCacheTime.remove(playerId);
|
|
}
|
|
|
|
/// Get last-known track for a player (survives idle-out)
|
|
Track? getLastKnownTrack(String playerId) => _lastKnownTrackCache[playerId];
|
|
|
|
/// Save last-known track for a player (only called when a real track is playing/paused)
|
|
void setLastKnownTrack(String playerId, Track track) {
|
|
_lastKnownTrackCache[playerId] = track;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLEAR ALL
|
|
// ============================================================================
|
|
|
|
/// Clear all detail caches (call on disconnect/reconnect)
|
|
void clearAllDetailCaches() {
|
|
_albumTracksCache.clear();
|
|
_albumTracksCacheTime.clear();
|
|
_playlistTracksCache.clear();
|
|
_playlistTracksCacheTime.clear();
|
|
_artistAlbumsCache.clear();
|
|
_artistAlbumsCacheTime.clear();
|
|
_searchCache.clear();
|
|
_searchCacheTime.clear();
|
|
_playerTrackCache.clear();
|
|
_playerTrackCacheTime.clear();
|
|
_audiobookDetailCache.clear();
|
|
_audiobookDetailCacheTime.clear();
|
|
_podcastEpisodesCache.clear();
|
|
_podcastEpisodesCacheTime.clear();
|
|
_logger.log('🗑️ All detail caches cleared');
|
|
// Also clear DB detail cache
|
|
() async {
|
|
try {
|
|
if (DatabaseService.instance.isInitialized) {
|
|
await DatabaseService.instance.clearDetailTrackCache();
|
|
}
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to clear detail track DB cache: $e');
|
|
}
|
|
}();
|
|
}
|
|
|
|
/// Clear all caches
|
|
void clearAll() {
|
|
invalidateHomeCache();
|
|
clearAllDetailCaches();
|
|
_cachedPlayers = null;
|
|
_cachedSelectedPlayer = null;
|
|
_playersLastFetched = null;
|
|
}
|
|
|
|
// ============================================================================
|
|
// DATABASE PERSISTENCE FOR HOME ROWS
|
|
// ============================================================================
|
|
|
|
/// Load home row data from database for instant display on startup
|
|
/// Returns the existing future if already loading, to prevent duplicate loads
|
|
Future<void> loadHomeRowsFromDatabase() async {
|
|
if (_homeRowsLoadFuture != null) {
|
|
// Already loading, return the existing future
|
|
return _homeRowsLoadFuture!;
|
|
}
|
|
if (_homeRowsLoaded) return;
|
|
|
|
// Create the load future
|
|
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
|
|
await _homeRowsLoadFuture;
|
|
_homeRowsLoadFuture = null; // Clear after completion
|
|
}
|
|
|
|
/// Internal implementation of home row loading
|
|
Future<void> _doLoadHomeRowsFromDatabase() async {
|
|
try {
|
|
if (!DatabaseService.instance.isInitialized) return;
|
|
|
|
// Load recent albums
|
|
final recentData = await DatabaseService.instance.getHomeRowCache('recent_albums');
|
|
if (recentData != null) {
|
|
try {
|
|
final items = (jsonDecode(recentData.itemsJson) as List)
|
|
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
_cachedRecentAlbums = items;
|
|
_recentAlbumsLastFetched = recentData.lastUpdated;
|
|
_logger.log('📦 Loaded ${items.length} recent albums from database');
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to parse recent albums: $e');
|
|
}
|
|
}
|
|
|
|
// Load discover artists
|
|
final artistsData = await DatabaseService.instance.getHomeRowCache('discover_artists');
|
|
if (artistsData != null) {
|
|
try {
|
|
final items = (jsonDecode(artistsData.itemsJson) as List)
|
|
.map((json) => Artist.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
_cachedDiscoverArtists = items;
|
|
_discoverArtistsLastFetched = artistsData.lastUpdated;
|
|
_logger.log('📦 Loaded ${items.length} discover artists from database');
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to parse discover artists: $e');
|
|
}
|
|
}
|
|
|
|
// Load discover albums
|
|
final albumsData = await DatabaseService.instance.getHomeRowCache('discover_albums');
|
|
if (albumsData != null) {
|
|
try {
|
|
final items = (jsonDecode(albumsData.itemsJson) as List)
|
|
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
_cachedDiscoverAlbums = items;
|
|
_discoverAlbumsLastFetched = albumsData.lastUpdated;
|
|
_logger.log('📦 Loaded ${items.length} discover albums from database');
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to parse discover albums: $e');
|
|
}
|
|
}
|
|
|
|
// Load discovery folders
|
|
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
|
|
if (foldersData != null) {
|
|
try {
|
|
final items = (jsonDecode(foldersData.itemsJson) as List)
|
|
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
_cachedDiscoveryFolders = items;
|
|
_discoveryFoldersLastFetched = foldersData.lastUpdated;
|
|
_logger.log('📦 Loaded ${items.length} discovery folders from database');
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to parse discovery folders: $e');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_logger.log('⚠️ Error loading home rows from database: $e');
|
|
} finally {
|
|
_homeRowsLoaded = true;
|
|
}
|
|
}
|
|
|
|
/// Persist home row data to database (fire-and-forget)
|
|
void _persistHomeRowToDatabase(String rowType, List<Map<String, dynamic>> items) {
|
|
() async {
|
|
try {
|
|
if (!DatabaseService.instance.isInitialized) return;
|
|
await DatabaseService.instance.saveHomeRowCache(rowType, jsonEncode(items));
|
|
_logger.log('💾 Persisted $rowType to database');
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to persist $rowType: $e');
|
|
}
|
|
}();
|
|
}
|
|
|
|
// ============================================================================
|
|
// LRU CACHE EVICTION
|
|
// ============================================================================
|
|
|
|
/// Evict oldest entries from cache maps to enforce size limit (LRU eviction)
|
|
/// Uses the timestamp map to determine which entries are oldest
|
|
void _evictOldestEntries<K, V>(
|
|
Map<K, V> cache,
|
|
Map<K, DateTime> cacheTime,
|
|
int maxSize,
|
|
) {
|
|
if (cache.length <= maxSize) return;
|
|
|
|
// Sort keys by timestamp (oldest first)
|
|
final sortedKeys = cacheTime.keys.toList()
|
|
..sort((a, b) => (cacheTime[a] ?? DateTime.now())
|
|
.compareTo(cacheTime[b] ?? DateTime.now()));
|
|
|
|
// Remove oldest entries until we're at maxSize
|
|
final keysToRemove = sortedKeys.take(cache.length - maxSize);
|
|
for (final key in keysToRemove) {
|
|
cache.remove(key);
|
|
cacheTime.remove(key);
|
|
}
|
|
|
|
if (keysToRemove.isNotEmpty) {
|
|
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries');
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// AUDIOBOOK DETAIL CACHING
|
|
// ============================================================================
|
|
|
|
final Map<String, String> _audiobookDetailCache = {}; // cacheKey -> JSON
|
|
final Map<String, DateTime> _audiobookDetailCacheTime = {};
|
|
|
|
bool isAudiobookDetailCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final cacheTime = _audiobookDetailCacheTime[cacheKey];
|
|
return _audiobookDetailCache.containsKey(cacheKey) &&
|
|
cacheTime != null &&
|
|
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
|
|
}
|
|
|
|
String? getCachedAudiobookDetail(String cacheKey) => _audiobookDetailCache[cacheKey];
|
|
|
|
void setCachedAudiobookDetail(String cacheKey, String audiobookJson) {
|
|
_audiobookDetailCache[cacheKey] = audiobookJson;
|
|
_audiobookDetailCacheTime[cacheKey] = DateTime.now();
|
|
_evictOldestEntries(_audiobookDetailCache, _audiobookDetailCacheTime, _maxDetailCacheSize);
|
|
_logger.log('✅ Cached audiobook detail for $cacheKey');
|
|
_persistDetailData('audiobook', cacheKey, audiobookJson);
|
|
}
|
|
|
|
Future<String?> getAudiobookDetailWithDbFallback(String cacheKey) async {
|
|
final memCached = _audiobookDetailCache[cacheKey];
|
|
if (memCached != null) return memCached;
|
|
|
|
try {
|
|
if (!DatabaseService.instance.isInitialized) return null;
|
|
final dbResult = await DatabaseService.instance.getDetailTracks('audiobook', cacheKey);
|
|
if (dbResult == null) return null;
|
|
|
|
_audiobookDetailCache[cacheKey] = dbResult.tracksJson;
|
|
_audiobookDetailCacheTime[cacheKey] = dbResult.lastFetched;
|
|
_logger.log('📦 Loaded audiobook detail from database for $cacheKey');
|
|
return dbResult.tracksJson;
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to load audiobook detail from database: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PODCAST EPISODE CACHING
|
|
// ============================================================================
|
|
|
|
final Map<String, List<MediaItem>> _podcastEpisodesCache = {};
|
|
final Map<String, DateTime> _podcastEpisodesCacheTime = {};
|
|
|
|
bool isPodcastEpisodesCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
|
if (forceRefresh) return false;
|
|
final cacheTime = _podcastEpisodesCacheTime[cacheKey];
|
|
return _podcastEpisodesCache.containsKey(cacheKey) &&
|
|
cacheTime != null &&
|
|
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
|
|
}
|
|
|
|
List<MediaItem>? getCachedPodcastEpisodes(String cacheKey) => _podcastEpisodesCache[cacheKey];
|
|
|
|
void setCachedPodcastEpisodes(String cacheKey, List<MediaItem> episodes) {
|
|
_podcastEpisodesCache[cacheKey] = episodes;
|
|
_podcastEpisodesCacheTime[cacheKey] = DateTime.now();
|
|
_evictOldestEntries(_podcastEpisodesCache, _podcastEpisodesCacheTime, _maxDetailCacheSize);
|
|
_logger.log('✅ Cached ${episodes.length} episodes for podcast $cacheKey');
|
|
_persistDetailMediaItems('podcast', cacheKey, episodes);
|
|
}
|
|
|
|
Future<List<MediaItem>?> getPodcastEpisodesWithDbFallback(String cacheKey) async {
|
|
final memCached = _podcastEpisodesCache[cacheKey];
|
|
if (memCached != null) return memCached;
|
|
|
|
try {
|
|
if (!DatabaseService.instance.isInitialized) return null;
|
|
final dbResult = await DatabaseService.instance.getDetailTracks('podcast', cacheKey);
|
|
if (dbResult == null) return null;
|
|
|
|
final episodes = (jsonDecode(dbResult.tracksJson) as List)
|
|
.map((json) => MediaItem.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
_podcastEpisodesCache[cacheKey] = episodes;
|
|
_podcastEpisodesCacheTime[cacheKey] = dbResult.lastFetched;
|
|
_logger.log('📦 Loaded ${episodes.length} podcast episodes from database for $cacheKey');
|
|
return episodes;
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to load podcast episodes from database: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// DATABASE PERSISTENCE FOR DETAIL TRACKS
|
|
// ============================================================================
|
|
|
|
/// Persist detail tracks to database (fire-and-forget)
|
|
void _persistDetailTracks(String parentType, String cacheKey, List<Track> tracks) {
|
|
_persistDetailData(parentType, cacheKey, jsonEncode(tracks.map((t) => t.toJson()).toList()));
|
|
}
|
|
|
|
/// Persist detail media items to database (fire-and-forget)
|
|
void _persistDetailMediaItems(String parentType, String cacheKey, List<MediaItem> items) {
|
|
_persistDetailData(parentType, cacheKey, jsonEncode(items.map((i) => i.toJson()).toList()));
|
|
}
|
|
|
|
/// Persist raw JSON detail data to database (fire-and-forget)
|
|
void _persistDetailData(String parentType, String cacheKey, String dataJson) {
|
|
() async {
|
|
try {
|
|
if (!DatabaseService.instance.isInitialized) return;
|
|
await DatabaseService.instance.saveDetailTracks(parentType, cacheKey, dataJson);
|
|
_logger.log('💾 Persisted detail data ($parentType/$cacheKey) to database');
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to persist detail data: $e');
|
|
}
|
|
}();
|
|
}
|
|
|
|
/// Get album tracks with database fallback (memory → DB → null)
|
|
Future<List<Track>?> getAlbumTracksWithDbFallback(String cacheKey) async {
|
|
// Check memory cache first
|
|
final memCached = _albumTracksCache[cacheKey];
|
|
if (memCached != null) return memCached;
|
|
|
|
// Try database
|
|
try {
|
|
if (!DatabaseService.instance.isInitialized) return null;
|
|
final dbResult = await DatabaseService.instance.getDetailTracks('album', cacheKey);
|
|
if (dbResult == null) return null;
|
|
|
|
final tracks = (jsonDecode(dbResult.tracksJson) as List)
|
|
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
// Warm memory cache
|
|
_albumTracksCache[cacheKey] = tracks;
|
|
_albumTracksCacheTime[cacheKey] = dbResult.lastFetched;
|
|
_logger.log('📦 Loaded ${tracks.length} album tracks from database for $cacheKey');
|
|
return tracks;
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to load album tracks from database: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Get playlist tracks with database fallback (memory → DB → null)
|
|
Future<List<Track>?> getPlaylistTracksWithDbFallback(String cacheKey) async {
|
|
// Check memory cache first
|
|
final memCached = _playlistTracksCache[cacheKey];
|
|
if (memCached != null) return memCached;
|
|
|
|
// Try database
|
|
try {
|
|
if (!DatabaseService.instance.isInitialized) return null;
|
|
final dbResult = await DatabaseService.instance.getDetailTracks('playlist', cacheKey);
|
|
if (dbResult == null) return null;
|
|
|
|
final tracks = (jsonDecode(dbResult.tracksJson) as List)
|
|
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
// Warm memory cache
|
|
_playlistTracksCache[cacheKey] = tracks;
|
|
_playlistTracksCacheTime[cacheKey] = dbResult.lastFetched;
|
|
_logger.log('📦 Loaded ${tracks.length} playlist tracks from database for $cacheKey');
|
|
return tracks;
|
|
} catch (e) {
|
|
_logger.log('⚠️ Failed to load playlist tracks from database: $e');
|
|
return null;
|
|
}
|
|
}
|
|
}
|