mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-24 16:54:03 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1027,7 +1027,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user