perf: optimize widget rebuilds and reduce allocations

- Cache SharedPreferences instance in DownloadHistoryNotifier and DownloadQueueNotifier
- Precompile regex for folder sanitization and year extraction
- Use indexWhere instead of firstWhere with placeholder object
- Use selective watch for downloadQueueProvider (queuedCount, items)
- Pass Track directly to _buildTrackTile instead of index lookup
- Pass historyItems as parameter to _buildRecentAccess
- Add extended metadata (genre, label, copyright) support for MP3
This commit is contained in:
zarzet
2026-01-20 03:25:33 +07:00
parent 03027813c1
commit c36497e87c
6 changed files with 114 additions and 83 deletions
+7 -9
View File
@@ -1,18 +1,9 @@
# Changelog
## [Unreleased]
## [3.1.3] - 2026-01-19
### Added
- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory
- Cover images no longer disappear when app is closed or device restarts
- Cache stored in `app_flutter/cover_cache/` directory (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Covers are cached when displayed in History, Home, Album, Artist, or any other screen
- New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management
- **External LRC Lyrics File Support**: Option to save lyrics as separate .lrc files for compatibility with external music players
- New "Lyrics Mode" setting in Settings > Download > Lyrics section
- Three modes available:
@@ -28,6 +19,13 @@
- Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3
- Respects "Ask quality before download" setting - uses default quality if disabled
- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory
- Cover images no longer disappear when app is closed or device restarts
- Cache stored in `app_flutter/cover_cache/` directory (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Covers are cached when displayed in History, Home, Album, Artist, or any other screen
- New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management
- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer
- New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre`
- Metadata fetched during `enrichTrack()` via Deezer album API
+48 -24
View File
@@ -26,6 +26,10 @@ String? _normalizeOptionalString(String? value) {
return trimmed;
}
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
final _trailingDotsRegex = RegExp(r'\.+$');
final _yearRegex = RegExp(r'^(\d{4})');
class DownloadHistoryItem {
final String id;
final String trackName;
@@ -143,6 +147,7 @@ class DownloadHistoryState {
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const _storageKey = 'download_history';
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
bool _isLoaded = false;
@override
@@ -162,7 +167,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final jsonStr = prefs.getString(_storageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
@@ -223,7 +228,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<void> _saveToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
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');
@@ -385,6 +390,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
static const _cleanupInterval = 50;
static const _queueStorageKey = 'download_queue';
final NotificationService _notificationService = NotificationService();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
int _totalQueuedAtStart = 0;
int _completedInSession = 0;
int _failedInSession = 0;
@@ -410,7 +416,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_isLoaded = true;
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final jsonStr = prefs.getString(_queueStorageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
@@ -448,7 +454,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Future<void> _saveQueueToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final pendingItems = state.items
.where(
@@ -783,15 +789,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String _sanitizeFolderName(String name) {
return name
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
.replaceAll(_invalidFolderChars, '_')
.replaceAll(_trailingDotsRegex, '') // Remove trailing dots
.trim();
}
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
final match = _yearRegex.firstMatch(releaseDate);
return match?.group(1);
}
@@ -1216,7 +1222,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
Future<void> _embedMetadataToMp3(String mp3Path, Track track) async {
Future<void> _embedMetadataToMp3(
String mp3Path,
Track track, {
String? genre,
String? label,
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
String? coverPath;
@@ -1283,6 +1295,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
metadata['ISRC'] = track.isrc!;
}
if (genre != null && genre.isNotEmpty) {
metadata['GENRE'] = genre;
_log.d('Adding GENRE to MP3: $genre');
}
if (label != null && label.isNotEmpty) {
metadata['ORGANIZATION'] = label;
_log.d('Adding ORGANIZATION (label) to MP3: $label');
}
if (copyright != null && copyright.isNotEmpty) {
metadata['COPYRIGHT'] = copyright;
_log.d('Adding COPYRIGHT to MP3: $copyright');
}
_log.d('MP3 Metadata map content: $metadata');
if (settings.embedLyrics) {
@@ -1447,29 +1472,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
final currentItems = state.items;
final nextItem = currentItems.firstWhere(
final nextIndex = currentItems.indexWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
id: '',
track: const Track(
id: '',
name: '',
artistName: '',
albumName: '',
duration: 0,
),
service: '',
createdAt: DateTime.now(),
),
);
if (nextItem.id.isEmpty) {
if (nextIndex == -1) {
_log.d(
'No more items to process (checked ${currentItems.length} items)',
);
break;
}
final nextItem = currentItems[nextIndex];
_log.d(
'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})',
);
@@ -1956,7 +1969,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
DownloadStatus.downloading,
progress: 0.99,
);
await _embedMetadataToMp3(mp3Path, trackToDownload);
final mp3BackendGenre = result['genre'] as String?;
final mp3BackendLabel = result['label'] as String?;
final mp3BackendCopyright = result['copyright'] as String?;
await _embedMetadataToMp3(
mp3Path,
trackToDownload,
genre: mp3BackendGenre ?? genre,
label: mp3BackendLabel ?? label,
copyright: mp3BackendCopyright,
);
} else {
_log.w('MP3 conversion failed, keeping FLAC file');
}
+24 -23
View File
@@ -46,16 +46,15 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
}
}
void _downloadTrack(int index) {
final trackState = ref.read(trackProvider);
if (index >= 0 && index < trackState.tracks.length) {
final track = trackState.tracks[index];
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(
track,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadAll() {
@@ -89,8 +88,10 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final queueState = ref.watch(downloadQueueProvider);
final queuedCount =
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
@@ -146,13 +147,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
if (trackState.tracks.length > 1)
if (tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon(
onPressed: _downloadAll,
icon: const Icon(Icons.download),
label: Text('Download All (${trackState.tracks.length})'),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
@@ -160,11 +161,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
Expanded(
child: trackState.tracks.isEmpty
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: trackState.tracks.length,
itemBuilder: (context, index) => _buildTrackTile(index, colorScheme),
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
@@ -180,13 +182,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music),
),
label: 'Queue',
@@ -261,8 +263,7 @@ child: CachedNetworkImage(
);
}
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final isCollection = track.isCollection;
String subtitleText;
@@ -318,7 +319,7 @@ child: CachedNetworkImage(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
);
}
+10 -4
View File
@@ -548,7 +548,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (showRecentAccess)
SliverToBoxAdapter(
child: _buildRecentAccess(recentAccessItems, colorScheme),
child: _buildRecentAccess(
recentAccessItems,
historyItems,
colorScheme,
),
),
SliverToBoxAdapter(
@@ -666,9 +670,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
);
}
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
final historyItems = ref.read(downloadHistoryProvider).items;
Widget _buildRecentAccess(
List<RecentAccessItem> items,
List<DownloadHistoryItem> historyItems,
ColorScheme colorScheme,
) {
// Group download history by album
final albumGroups = <String, List<DownloadHistoryItem>>{};
for (final h in historyItems) {
+7 -6
View File
@@ -11,20 +11,20 @@ class QueueScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final queueState = ref.watch(downloadQueueProvider);
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.queueTitle),
actions: [
if (queueState.items.isNotEmpty)
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: context.l10n.queueClearCompleted,
),
if (queueState.items.isNotEmpty)
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
@@ -32,11 +32,12 @@ class QueueScreen extends ConsumerWidget {
),
],
),
body: queueState.items.isEmpty
body: items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: queueState.items.length,
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
itemCount: items.length,
itemBuilder: (context, index) =>
_buildQueueItem(context, ref, items[index], colorScheme),
),
);
}
+18 -17
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -44,22 +45,22 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
}
void _downloadTrack(int index) {
final trackState = ref.read(trackProvider);
if (index >= 0 && index < trackState.tracks.length) {
final track = trackState.tracks[index];
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(
track,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
@@ -96,11 +97,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
),
Expanded(
child: trackState.tracks.isEmpty
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: trackState.tracks.length,
itemBuilder: (context, index) => _buildTrackTile(index, colorScheme),
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
@@ -130,8 +132,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
);
}
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
@@ -175,9 +176,9 @@ child: CachedNetworkImage(
),
trailing: IconButton(
icon: Icon(Icons.download, color: colorScheme.primary),
onPressed: () => _downloadTrack(index),
onPressed: () => _downloadTrack(track),
),
onTap: () => _downloadTrack(index),
onTap: () => _downloadTrack(track),
);
}
}