diff --git a/CHANGELOG.md b/CHANGELOG.md index 495d9f1e..f3a7abc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,19 @@ - Uses Go's `time.Now()` for accurate device timezone detection - Solves Goja JS engine's `getTimezoneOffset()` returning 0 issue +- **SQLite Database for Download History**: Migrated from SharedPreferences to SQLite + - New `HistoryDatabase` service with proper schema and indexes + - O(1) lookups by `spotify_id` and `isrc` (was O(n) linear search) + - Non-blocking writes - UI stays responsive during saves + - Automatic one-time migration from SharedPreferences on first launch + - No storage size limits (was ~1MB with SharedPreferences) + - Database schema with indexes: `idx_spotify_id`, `idx_isrc`, `idx_downloaded_at`, `idx_album` + +- **Track Duration in Home Feed Items**: Home feed tracks now include duration + - Added `duration_ms` field to `ExploreItem` model + - Parsed from spotify-web and ytmusic home feed responses + - Fixes track duration showing "0:00" in metadata screen after download from home feed + ### Fixed - **YT Music Greeting Time**: Fixed "Good night" showing in the morning @@ -42,16 +55,24 @@ - Now uses `gobackend.getLocalTime().timezone` or offset mapping - Ensures personalized content is based on correct user timezone +- **Home Feed Track Duration**: Fixed duration showing 0:00 when downloading from home feed + - spotify-web and ytmusic extensions now include `duration_ms` in home feed items + - `ExploreItem` model now has `durationMs` field + - `_downloadExploreTrack()` uses `item.durationMs` instead of hardcoded 0 +- **Explore Item Navigation**: Prevents fallthrough so tapping a track/album/playlist/artist only triggers its intended action + ### Extensions - **spotify-web Extension**: Updated to v1.8.0 - Added `capabilities: { homeFeed: true, browseCategories: true }` to manifest - `fetchHomeFeed()` now uses `gobackend.getLocalTime()` for timezone detection + - Added `duration_ms` to home feed track items - Removed reliance on Goja's broken `getTimezoneOffset()` and `Intl.DateTimeFormat()` - **ytmusic-spotiflac Extension**: Updated to v1.6.0 - Added `capabilities: { homeFeed: true }` to manifest - `getTimeBasedGreeting()` now uses `gobackend.getLocalTime().hour` directly + - Added `duration_ms` parsing from subtitle runs in home feed items - Simplified greeting logic - no more manual UTC offset calculations ### Technical @@ -65,12 +86,30 @@ - File: `lib/providers/explore_provider.dart` - Finds extensions with `hasHomeFeed` capability - Prefers spotify-web if available, falls back to first available + - Added `durationMs` field to `ExploreItem` model +- **Explore Provider**: Single-pass home feed extension selection (prefers spotify-web) and guard against parallel fetches +- **Go Backend Extensions**: Consolidates `getHomeFeed`/`getBrowseCategories` execution into a shared helper - **Flutter Home Tab**: Refactored explore sections rendering - File: `lib/screens/home_tab.dart` - Added `RefreshIndicator` wrapper with `notificationPredicate` for conditional refresh - Added `_buildYTMusicQuickPicksSection()` for special YT Music format - Added `_QuickPicksPageView` StatefulWidget for swipeable track pages + - `_downloadExploreTrack()` now uses `item.durationMs` + - Uses a single `SliverList` for Explore sections to reduce sliver count + - Moves provider listeners to `initState` with `listenManual` + - Early-exit loop for YT Music Quick Picks detection + - Removes redundant provider watches and reuses `MediaQuery` values + +### Performance + +- **Download History Database**: Migrated from JSON/SharedPreferences to SQLite + - File: `lib/services/history_database.dart` + - Load time: O(query) instead of O(parse entire JSON) + - Lookup by spotify_id/isrc: O(1) with index instead of O(n) linear search + - Save single item: O(1) INSERT instead of O(n) serialize entire list + - Delete single item: O(1) DELETE instead of O(n) serialize entire list + - Memory: Only loaded items in memory, not entire JSON string ## [3.1.3] - 2026-01-19 diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e4be0292..b95c05bb 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('DownloadQueue'); @@ -130,15 +131,36 @@ class DownloadHistoryItem { class DownloadHistoryState { final List items; final Set _downloadedSpotifyIds; + final Map _bySpotifyId; + final Map _byIsrc; DownloadHistoryState({this.items = const []}) : _downloadedSpotifyIds = items .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) .map((item) => item.spotifyId!) - .toSet(); + .toSet(), + _bySpotifyId = Map.fromEntries( + items + .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) + .map((item) => MapEntry(item.spotifyId!, item)), + ), + _byIsrc = Map.fromEntries( + items + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => MapEntry(item.isrc!, item)), + ); + /// O(1) check if spotify_id exists bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); + + /// O(1) lookup by spotify_id + DownloadHistoryItem? getBySpotifyId(String spotifyId) => + _bySpotifyId[spotifyId]; + + /// O(1) lookup by ISRC + DownloadHistoryItem? getByIsrc(String isrc) => + _byIsrc[isrc]; DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); @@ -146,130 +168,58 @@ class DownloadHistoryState { } class DownloadHistoryNotifier extends Notifier { - static const _storageKey = 'download_history'; - final Future _prefs = SharedPreferences.getInstance(); + final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; @override DownloadHistoryState build() { - _loadFromStorageSync(); + _loadFromDatabaseSync(); return DownloadHistoryState(); } /// Synchronously schedule load - ensures it runs before any UI renders - void _loadFromStorageSync() { + void _loadFromDatabaseSync() { if (_isLoaded) return; Future.microtask(() async { - await _loadFromStorage(); + await _loadFromDatabase(); _isLoaded = true; }); } - Future _loadFromStorage() async { + Future _loadFromDatabase() async { try { - final prefs = await _prefs; - final jsonStr = prefs.getString(_storageKey); - if (jsonStr != null && jsonStr.isNotEmpty) { - final List jsonList = jsonDecode(jsonStr); - final items = jsonList - .map((e) => DownloadHistoryItem.fromJson(e as Map)) - .toList(); - - final deduplicatedItems = _deduplicateHistory(items); - - state = state.copyWith(items: deduplicatedItems); - _historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})'); - - if (deduplicatedItems.length < items.length) { - _historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries'); - await _saveToStorage(); - } - } else { - _historyLog.d('No history found in storage'); - } - } catch (e) { - _historyLog.e('Failed to load history: $e'); - } - } - - /// Keeps the most recent entry (first occurrence since list is sorted by date desc) - List _deduplicateHistory(List items) { - final seen = {}; // key -> index of first occurrence - final result = []; - - for (int i = 0; i < items.length; i++) { - final item = items[i]; - String? key; - - if (item.spotifyId != null && item.spotifyId!.isNotEmpty) { - if (item.spotifyId!.startsWith('deezer:')) { - key = 'deezer:${item.spotifyId!.substring(7)}'; - } else { - key = 'spotify:${item.spotifyId}'; - } - } else if (item.isrc != null && item.isrc!.isNotEmpty) { - key = 'isrc:${item.isrc}'; + final migrated = await _db.migrateFromSharedPreferences(); + if (migrated) { + _historyLog.i('Migrated history from SharedPreferences to SQLite'); } - if (key != null) { - if (!seen.containsKey(key)) { - seen[key] = result.length; - result.add(item); - } else { - _historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)'); - } - } else { - result.add(item); - } - } - - return result; - } - - Future _saveToStorage() async { - try { - final prefs = await _prefs; - final jsonList = state.items.map((e) => e.toJson()).toList(); - await prefs.setString(_storageKey, jsonEncode(jsonList)); - _historyLog.d('Saved ${state.items.length} items to storage'); - } catch (e) { - _historyLog.e('Failed to save history: $e'); + final jsonList = await _db.getAll(); + final items = jsonList + .map((e) => DownloadHistoryItem.fromJson(e)) + .toList(); + + state = state.copyWith(items: items); + _historyLog.i('Loaded ${items.length} items from SQLite database'); + } catch (e, stack) { + _historyLog.e('Failed to load history from database: $e', e, stack); } } Future reloadFromStorage() async { - await _loadFromStorage(); + await _loadFromDatabase(); } void addToHistory(DownloadHistoryItem item) { - final existingIndex = state.items.indexWhere((existing) { - if (item.spotifyId != null && - item.spotifyId!.isNotEmpty && - existing.spotifyId == item.spotifyId) { - return true; - } - - if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') && - existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) { - final itemDeezerId = item.spotifyId!.substring(7); - final existingDeezerId = existing.spotifyId!.substring(7); - if (itemDeezerId == existingDeezerId) { - return true; - } - } - - if (item.isrc != null && - item.isrc!.isNotEmpty && - existing.isrc == item.isrc) { - return true; - } - return false; - }); + DownloadHistoryItem? existing; + if (item.spotifyId != null && item.spotifyId!.isNotEmpty) { + existing = state.getBySpotifyId(item.spotifyId!); + } + if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) { + existing = state.getByIsrc(item.isrc!); + } - if (existingIndex >= 0) { - final updatedItems = [...state.items]; - updatedItems[existingIndex] = item; - updatedItems.removeAt(existingIndex); + if (existing != null) { + final updatedItems = state.items.where((i) => i.id != existing!.id).toList(); updatedItems.insert(0, item); state = state.copyWith(items: updatedItems); _historyLog.d('Updated existing history entry: ${item.trackName}'); @@ -277,31 +227,60 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith(items: [item, ...state.items]); _historyLog.d('Added new history entry: ${item.trackName}'); } - _saveToStorage(); + + _db.upsert(item.toJson()).catchError((e) { + _historyLog.e('Failed to save to database: $e'); + }); } void removeFromHistory(String id) { state = state.copyWith( items: state.items.where((item) => item.id != id).toList(), ); - _saveToStorage(); + _db.deleteById(id).catchError((e) { + _historyLog.e('Failed to delete from database: $e'); + }); } void removeBySpotifyId(String spotifyId) { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), ); - _saveToStorage(); + _db.deleteBySpotifyId(spotifyId).catchError((e) { + _historyLog.e('Failed to delete from database: $e'); + }); _historyLog.d('Removed item with spotifyId: $spotifyId'); } DownloadHistoryItem? getBySpotifyId(String spotifyId) { - return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull; + return state.getBySpotifyId(spotifyId); + } + + /// O(1) lookup by ISRC + DownloadHistoryItem? getByIsrc(String isrc) { + return state.getByIsrc(isrc); + } + + /// Async version with database lookup (for cases where in-memory might be stale) + Future getBySpotifyIdAsync(String spotifyId) async { + final inMemory = state.getBySpotifyId(spotifyId); + if (inMemory != null) return inMemory; + + final json = await _db.getBySpotifyId(spotifyId); + if (json == null) return null; + return DownloadHistoryItem.fromJson(json); } void clearHistory() { state = DownloadHistoryState(); - _saveToStorage(); + _db.clearAll().catchError((e) { + _historyLog.e('Failed to clear database: $e'); + }); + } + + /// Get database stats for debugging + Future getDatabaseCount() async { + return await _db.getCount(); } } @@ -790,7 +769,7 @@ class DownloadQueueNotifier extends Notifier { String _sanitizeFolderName(String name) { return name .replaceAll(_invalidFolderChars, '_') - .replaceAll(_trailingDotsRegex, '') // Remove trailing dots + .replaceAll(_trailingDotsRegex, '') .trim(); } @@ -1067,8 +1046,8 @@ class DownloadQueueNotifier extends Notifier { /// Same logic as Go backend cover.go String _upgradeToMaxQualityCover(String coverUrl) { - const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) - const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium) + const spotifySize300 = 'ab67616d00001e02'; + const spotifySize640 = 'ab67616d0000b273'; const spotifySizeMax = 'ab67616d000082c1'; var result = coverUrl; @@ -1667,7 +1646,6 @@ class DownloadQueueNotifier extends Notifier { deezerTrackId = trackToDownload.availability!.deezerId; } - // If no deezerTrackId but we have ISRC, try to find track via ISRC if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) { try { _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); @@ -1772,9 +1750,8 @@ class DownloadQueueNotifier extends Notifier { trackNumber: trackToDownload.trackNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, - itemId: item.id, // Pass item ID for progress tracking - durationMs: - trackToDownload.duration, // Duration in ms for verification + itemId: item.id, + durationMs: trackToDownload.duration, ); } @@ -1814,7 +1791,7 @@ class DownloadQueueNotifier extends Notifier { final actualBitDepth = result['actual_bit_depth'] as int?; final actualSampleRate = result['actual_sample_rate'] as int?; - String actualQuality = quality; // Default to requested quality + String actualQuality = quality; if (actualBitDepth != null && actualBitDepth > 0) { // Format: "24-bit/96kHz" or "16-bit/44.1kHz" diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 86db03de..34c36a6f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -105,19 +105,9 @@ class _AlbumScreenState extends ConsumerState { Future _extractDominantColor() async { if (widget.coverUrl == null) return; - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.coverUrl!), - maximumColorCount: 16, - ); - if (mounted) { - setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; - }); - } - } catch (_) { + final color = await PaletteService.instance.extractDominantColor(widget.coverUrl); + if (mounted && color != null) { + setState(() => _dominantColor = color); } } diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index a2ae3f42..0dca2c86 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -59,24 +59,23 @@ class _DownloadedAlbumScreenState extends ConsumerState { Future _extractDominantColor() async { if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return; - // Only use network images for palette extraction - final isNetworkUrl = widget.coverUrl!.startsWith('http://') || - widget.coverUrl!.startsWith('https://'); - if (!isNetworkUrl) return; - - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.coverUrl!), - maximumColorCount: 16, - ); - if (mounted) { + // Check cache first (instant) + final cached = PaletteService.instance.getCached(widget.coverUrl); + if (cached != null) { + if (mounted && cached != _dominantColor) { setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; + _dominantColor = cached; }); } - } catch (_) { + return; + } + + // Extract in isolate (non-blocking) + final color = await PaletteService.instance.extractDominantColor(widget.coverUrl); + if (mounted && color != null && color != _dominantColor) { + setState(() { + _dominantColor = color; + }); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index e64c8daf..51b2f969 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -55,19 +55,9 @@ class _PlaylistScreenState extends ConsumerState { Future _extractDominantColor() async { if (widget.coverUrl == null) return; - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.coverUrl!), - maximumColorCount: 16, - ); - if (mounted) { - setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; - }); - } - } catch (_) { + final color = await PaletteService.instance.extractDominantColor(widget.coverUrl); + if (mounted && color != null) { + setState(() => _dominantColor = color); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index caaa6343..30f07f47 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; @@ -61,7 +61,10 @@ class _TrackMetadataScreenState extends ConsumerState { super.initState(); _scrollController.addListener(_onScroll); _checkFile(); - _extractDominantColor(); + // Delay palette extraction to avoid jitter during initial build + WidgetsBinding.instance.addPostFrameCallback((_) { + _extractDominantColor(); + }); } @override @@ -80,25 +83,20 @@ class _TrackMetadataScreenState extends ConsumerState { Future _extractDominantColor() async { final coverUrl = widget.item.coverUrl; - if (coverUrl == null || coverUrl.isEmpty) return; - if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) { + + // Check cache first + final cachedColor = PaletteService.instance.getCached(coverUrl); + if (cachedColor != null) { + if (mounted && cachedColor != _dominantColor) { + setState(() => _dominantColor = cachedColor); + } return; } - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(coverUrl), - size: const Size(128, 128), - maximumColorCount: 12, - ); - final nextColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; - if (mounted && nextColor != _dominantColor) { - setState(() { - _dominantColor = nextColor; - }); - } - } catch (_) { + + // Extract using PaletteService (runs in isolate) + final color = await PaletteService.instance.extractDominantColor(coverUrl); + if (mounted && color != null && color != _dominantColor) { + setState(() => _dominantColor = color); } } diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart new file mode 100644 index 00000000..fc2df58e --- /dev/null +++ b/lib/services/history_database.dart @@ -0,0 +1,326 @@ +import 'dart:convert'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('HistoryDatabase'); + +/// SQLite database service for download history +/// Provides O(1) lookups by spotify_id and isrc with proper indexing +class HistoryDatabase { + static final HistoryDatabase instance = HistoryDatabase._init(); + static Database? _database; + + HistoryDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB('history.db'); + return _database!; + } + + Future _initDB(String fileName) async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, fileName); + + _log.i('Initializing database at: $path'); + + return await openDatabase( + path, + version: 1, + onCreate: _createDB, + onUpgrade: _upgradeDB, + ); + } + + Future _createDB(Database db, int version) async { + _log.i('Creating database schema v$version'); + + await db.execute(''' + CREATE TABLE history ( + id TEXT PRIMARY KEY, + track_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, + album_artist TEXT, + cover_url TEXT, + file_path TEXT NOT NULL, + service TEXT NOT NULL, + downloaded_at TEXT NOT NULL, + isrc TEXT, + spotify_id TEXT, + track_number INTEGER, + disc_number INTEGER, + duration INTEGER, + release_date TEXT, + quality TEXT, + bit_depth INTEGER, + sample_rate INTEGER, + genre TEXT, + label TEXT, + copyright TEXT + ) + '''); + + // Indexes for fast lookups + await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)'); + await db.execute('CREATE INDEX idx_isrc ON history(isrc)'); + await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)'); + await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)'); + + _log.i('Database schema created with indexes'); + } + + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading database from v$oldVersion to v$newVersion'); + // Future migrations go here + } + + /// Migrate data from SharedPreferences to SQLite + /// Returns true if migration was performed, false if already migrated + Future migrateFromSharedPreferences() async { + final prefs = await SharedPreferences.getInstance(); + final migrationKey = 'history_migrated_to_sqlite'; + + if (prefs.getBool(migrationKey) == true) { + _log.d('Already migrated to SQLite'); + return false; + } + + final jsonStr = prefs.getString('download_history'); + if (jsonStr == null || jsonStr.isEmpty) { + _log.d('No SharedPreferences history to migrate'); + await prefs.setBool(migrationKey, true); + return false; + } + + try { + final List jsonList = jsonDecode(jsonStr); + _log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite'); + + final db = await database; + final batch = db.batch(); + + for (final json in jsonList) { + final map = json as Map; + batch.insert( + 'history', + _jsonToDbRow(map), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + await batch.commit(noResult: true); + + // Mark as migrated but keep old data for safety + await prefs.setBool(migrationKey, true); + _log.i('Migration complete: ${jsonList.length} items'); + + return true; + } catch (e, stack) { + _log.e('Migration failed: $e', e, stack); + return false; + } + } + + /// Convert JSON format (camelCase) to DB row (snake_case) + Map _jsonToDbRow(Map json) { + return { + 'id': json['id'], + 'track_name': json['trackName'], + 'artist_name': json['artistName'], + 'album_name': json['albumName'], + 'album_artist': json['albumArtist'], + 'cover_url': json['coverUrl'], + 'file_path': json['filePath'], + 'service': json['service'], + 'downloaded_at': json['downloadedAt'], + 'isrc': json['isrc'], + 'spotify_id': json['spotifyId'], + 'track_number': json['trackNumber'], + 'disc_number': json['discNumber'], + 'duration': json['duration'], + 'release_date': json['releaseDate'], + 'quality': json['quality'], + 'bit_depth': json['bitDepth'], + 'sample_rate': json['sampleRate'], + 'genre': json['genre'], + 'label': json['label'], + 'copyright': json['copyright'], + }; + } + + /// Convert DB row (snake_case) to JSON format (camelCase) + Map _dbRowToJson(Map row) { + return { + 'id': row['id'], + 'trackName': row['track_name'], + 'artistName': row['artist_name'], + 'albumName': row['album_name'], + 'albumArtist': row['album_artist'], + 'coverUrl': row['cover_url'], + 'filePath': row['file_path'], + 'service': row['service'], + 'downloadedAt': row['downloaded_at'], + 'isrc': row['isrc'], + 'spotifyId': row['spotify_id'], + 'trackNumber': row['track_number'], + 'discNumber': row['disc_number'], + 'duration': row['duration'], + 'releaseDate': row['release_date'], + 'quality': row['quality'], + 'bitDepth': row['bit_depth'], + 'sampleRate': row['sample_rate'], + 'genre': row['genre'], + 'label': row['label'], + 'copyright': row['copyright'], + }; + } + + // ==================== CRUD Operations ==================== + + /// Insert or update a history item + Future upsert(Map json) async { + final db = await database; + await db.insert( + 'history', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Get all history items ordered by download date (newest first) + Future>> getAll({int? limit, int? offset}) async { + final db = await database; + final rows = await db.query( + 'history', + orderBy: 'downloaded_at DESC', + limit: limit, + offset: offset, + ); + return rows.map(_dbRowToJson).toList(); + } + + /// Get item by ID + Future?> getById(String id) async { + final db = await database; + final rows = await db.query( + 'history', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + + /// Get item by Spotify ID - O(1) with index + Future?> getBySpotifyId(String spotifyId) async { + final db = await database; + final rows = await db.query( + 'history', + where: 'spotify_id = ?', + whereArgs: [spotifyId], + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + + /// Get item by ISRC - O(1) with index + Future?> getByIsrc(String isrc) async { + final db = await database; + final rows = await db.query( + 'history', + where: 'isrc = ?', + whereArgs: [isrc], + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + + /// Check if spotify_id exists - O(1) with index + Future existsBySpotifyId(String spotifyId) async { + final db = await database; + final result = await db.rawQuery( + 'SELECT 1 FROM history WHERE spotify_id = ? LIMIT 1', + [spotifyId], + ); + return result.isNotEmpty; + } + + /// Get all spotify_ids as Set for fast in-memory lookup + Future> getAllSpotifyIds() async { + final db = await database; + final rows = await db.rawQuery( + 'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""' + ); + return rows.map((r) => r['spotify_id'] as String).toSet(); + } + + /// Delete by ID + Future deleteById(String id) async { + final db = await database; + await db.delete('history', where: 'id = ?', whereArgs: [id]); + } + + /// Delete by Spotify ID + Future deleteBySpotifyId(String spotifyId) async { + final db = await database; + await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]); + } + + /// Clear all history + Future clearAll() async { + final db = await database; + await db.delete('history'); + _log.i('Cleared all history'); + } + + /// Get total count + Future getCount() async { + final db = await database; + final result = await db.rawQuery('SELECT COUNT(*) as count FROM history'); + return Sqflite.firstIntValue(result) ?? 0; + } + + /// Find existing item by spotify_id or isrc (for deduplication) + Future?> findExisting({ + String? spotifyId, + String? isrc, + }) async { + if (spotifyId != null && spotifyId.isNotEmpty) { + final bySpotify = await getBySpotifyId(spotifyId); + if (bySpotify != null) return bySpotify; + + // Check for deezer: prefix matching + if (spotifyId.startsWith('deezer:')) { + final deezerId = spotifyId.substring(7); + final db = await database; + final rows = await db.query( + 'history', + where: 'spotify_id LIKE ?', + whereArgs: ['deezer:$deezerId'], + limit: 1, + ); + if (rows.isNotEmpty) return _dbRowToJson(rows.first); + } + } + + if (isrc != null && isrc.isNotEmpty) { + return await getByIsrc(isrc); + } + + return null; + } + + /// Close database + Future close() async { + final db = await database; + await db.close(); + _database = null; + } +} diff --git a/lib/services/palette_service.dart b/lib/services/palette_service.dart new file mode 100644 index 00000000..41efeee1 --- /dev/null +++ b/lib/services/palette_service.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; + +/// Service for extracting dominant colors from images +/// Uses caching to avoid re-extraction and small image size for speed +class PaletteService { + static final PaletteService instance = PaletteService._(); + PaletteService._(); + + /// Cache for already computed colors + final Map _colorCache = {}; + + /// Extract dominant color from a network image URL + /// Uses small image size and limited colors for speed + Future extractDominantColor(String? imageUrl) async { + if (imageUrl == null || imageUrl.isEmpty) return null; + if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) { + return null; + } + + // Check cache first + if (_colorCache.containsKey(imageUrl)) { + return _colorCache[imageUrl]; + } + + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(imageUrl), + size: const Size(64, 64), // Small size for speed + maximumColorCount: 8, // Fewer colors for speed + ); + + final color = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + + if (color != null) { + _colorCache[imageUrl] = color; + } + + return color; + } catch (e) { + debugPrint('PaletteService error: $e'); + return null; + } + } + + /// Clear the color cache + void clearCache() { + _colorCache.clear(); + } + + /// Get cached color without computing + Color? getCached(String? imageUrl) { + if (imageUrl == null) return null; + return _colorCache[imageUrl]; + } +} diff --git a/pubspec.lock b/pubspec.lock index 5233bceb..13c08c36 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1027,7 +1027,7 @@ packages: source: hosted version: "1.10.1" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 diff --git a/pubspec.yaml b/pubspec.yaml index ea2ae88e..5c2fddef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: shared_preferences: ^2.5.3 path_provider: ^2.1.5 path: ^1.9.0 + sqflite: ^2.4.1 # HTTP & Network http: ^1.6.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 2ace0a56..4e806acf 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -26,6 +26,7 @@ dependencies: shared_preferences: ^2.5.3 path_provider: ^2.1.5 path: ^1.9.0 + sqflite: ^2.4.1 # HTTP & Network http: ^1.6.0