mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-24 00:34:07 +02:00
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:
+7
-9
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user