perf: migrate history to SQLite and optimize palette extraction

- Add SQLite database for download history with O(1) indexed lookups
- Add in-memory Map indexes for O(1) getBySpotifyId/getByIsrc
- Automatic migration from SharedPreferences on first launch
- Fix PaletteService to use PaletteGenerator (isolate approach didn't work)
- Use small image size (64x64) and limited colors (8) for speed
- Add caching to avoid re-extraction
- All screens now use consistent PaletteService
- Update CHANGELOG with all v3.2.0 changes
This commit is contained in:
zarzet
2026-01-21 09:56:58 +07:00
parent 7a17de49b2
commit b899b54bb8
11 changed files with 557 additions and 177 deletions
+39
View File
@@ -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
+90 -113
View File
@@ -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<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds;
final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _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<DownloadHistoryItem>? items}) {
return DownloadHistoryState(items: items ?? this.items);
@@ -146,130 +168,58 @@ class DownloadHistoryState {
}
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const _storageKey = 'download_history';
final Future<SharedPreferences> _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<void> _loadFromStorage() async {
Future<void> _loadFromDatabase() async {
try {
final prefs = await _prefs;
final jsonStr = prefs.getString(_storageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
.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<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
final seen = <String, int>{}; // key -> index of first occurrence
final result = <DownloadHistoryItem>[];
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<void> _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<void> 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<DownloadHistoryState> {
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<DownloadHistoryItem?> 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<int> getDatabaseCount() async {
return await _db.getCount();
}
}
@@ -790,7 +769,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String _sanitizeFolderName(String name) {
return name
.replaceAll(_invalidFolderChars, '_')
.replaceAll(_trailingDotsRegex, '') // Remove trailing dots
.replaceAll(_trailingDotsRegex, '')
.trim();
}
@@ -1067,8 +1046,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// 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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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"
+4 -14
View File
@@ -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<AlbumScreen> {
Future<void> _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);
}
}
+15 -16
View File
@@ -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<DownloadedAlbumScreen> {
Future<void> _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;
});
}
}
+4 -14
View File
@@ -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<PlaylistScreen> {
Future<void> _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);
}
}
+17 -19
View File
@@ -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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
Future<void> _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);
}
}
+326
View File
@@ -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<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('history.db');
return _database!;
}
Future<Database> _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<void> _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<void> _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<bool> 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<dynamic> 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<String, dynamic>;
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<String, dynamic> _jsonToDbRow(Map<String, dynamic> 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<String, dynamic> _dbRowToJson(Map<String, dynamic> 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<void> upsert(Map<String, dynamic> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<bool> 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<Set<String>> 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<void> deleteById(String id) async {
final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]);
}
/// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
}
/// Clear all history
Future<void> clearAll() async {
final db = await database;
await db.delete('history');
_log.i('Cleared all history');
}
/// Get total count
Future<int> 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<Map<String, dynamic>?> 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<void> close() async {
final db = await database;
await db.close();
_database = null;
}
}
+59
View File
@@ -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<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
/// Uses small image size and limited colors for speed
Future<Color?> 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];
}
}
+1 -1
View File
@@ -1027,7 +1027,7 @@ packages:
source: hosted
version: "1.10.1"
sqflite:
dependency: transitive
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
+1
View File
@@ -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
+1
View File
@@ -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