mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
perf: optimize providers, caching, and reduce rebuilds
- Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access) - Pre-compute download counts in queue provider to avoid repeated filtering - Add identical() caching for RecentAccessView in HomeTab - Use selective watching for exploreProvider (sections, greeting, isLoading only) - Move isYTMusicQuickPicks computation to ExploreSection.fromJson() - Hoist static RegExp patterns to avoid repeated compilation - Use batch operations for iOS path migration in history_database - Replace containsKey+lookup with single lookup in palette_service - Pre-compute lowercase strings outside filter loops in logger - Fix _isLoaded race condition in DownloadHistoryNotifier
This commit is contained in:
@@ -22,6 +22,15 @@
|
||||
- **Deezer**: Track position fallback to index+1 when API returns 0
|
||||
- **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese
|
||||
|
||||
### Performance
|
||||
|
||||
- **Home Feed**: Precomputed Quick Picks section flag and reduced per-page allocations; explore state now watched by field to cut rebuilds
|
||||
- **Home Recent**: Cached recent-access aggregation and limited list allocations for recent downloads
|
||||
- **Settings/Theme/Recent**: Cached SharedPreferences instance to avoid repeated `getInstance()` calls
|
||||
- **History/DB**: Batched iOS path migration updates to reduce write overhead
|
||||
- **Download Queue**: Reduced polling allocations and avoided double-load scheduling for history
|
||||
- **Misc**: Precompiled regex in share intent, update dialog, extensions error parsing, log analysis, and LRC cleanup; faster palette cache hits and log filtering
|
||||
|
||||
---
|
||||
|
||||
## [3.2.0] - 2026-01-22
|
||||
|
||||
@@ -180,9 +180,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
void _loadFromDatabaseSync() {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
Future.microtask(() async {
|
||||
await _loadFromDatabase();
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -475,10 +475,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final currentItems = state.items;
|
||||
final itemsById = <String, DownloadItem>{};
|
||||
final itemIndexById = <String, int>{};
|
||||
int queuedCount = 0;
|
||||
int downloadingCount = 0;
|
||||
DownloadItem? firstDownloading;
|
||||
for (int i = 0; i < currentItems.length; i++) {
|
||||
final item = currentItems[i];
|
||||
itemsById[item.id] = item;
|
||||
itemIndexById[item.id] = i;
|
||||
if (item.status == DownloadStatus.downloading) {
|
||||
downloadingCount++;
|
||||
firstDownloading ??= item;
|
||||
}
|
||||
if (item.status == DownloadStatus.queued ||
|
||||
item.status == DownloadStatus.downloading) {
|
||||
queuedCount++;
|
||||
}
|
||||
}
|
||||
final progressUpdates = <String, _ProgressUpdate>{};
|
||||
|
||||
@@ -600,15 +611,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||
|
||||
final downloadingItems = state.items
|
||||
.where((i) => i.status == DownloadStatus.downloading)
|
||||
.toList();
|
||||
if (downloadingItems.isNotEmpty) {
|
||||
final trackName = downloadingItems.length == 1
|
||||
? downloadingItems.first.track.name
|
||||
: '${downloadingItems.length} downloads';
|
||||
final artistName = downloadingItems.length == 1
|
||||
? downloadingItems.first.track.artistName
|
||||
if (downloadingCount > 0 && firstDownloading != null) {
|
||||
final trackName = downloadingCount == 1
|
||||
? firstDownloading.track.name
|
||||
: '$downloadingCount downloads';
|
||||
final artistName = downloadingCount == 1
|
||||
? firstDownloading.track.artistName
|
||||
: 'Downloading...';
|
||||
|
||||
int notifProgress = bytesReceived;
|
||||
@@ -630,11 +638,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
trackName: downloadingItems.first.track.name,
|
||||
artistName: downloadingItems.first.track.artistName,
|
||||
trackName: firstDownloading.track.name,
|
||||
artistName: firstDownloading.track.artistName,
|
||||
progress: notifProgress,
|
||||
total: notifTotal > 0 ? notifTotal : 1,
|
||||
queueCount: state.queuedCount,
|
||||
queueCount: queuedCount,
|
||||
).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,21 +55,26 @@ class ExploreSection {
|
||||
final String uri;
|
||||
final String title;
|
||||
final List<ExploreItem> items;
|
||||
final bool isYTMusicQuickPicks;
|
||||
|
||||
const ExploreSection({
|
||||
required this.uri,
|
||||
required this.title,
|
||||
required this.items,
|
||||
this.isYTMusicQuickPicks = false,
|
||||
});
|
||||
|
||||
factory ExploreSection.fromJson(Map<String, dynamic> json) {
|
||||
final itemsList = json['items'] as List<dynamic>? ?? [];
|
||||
final items = itemsList
|
||||
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
final isQuickPicks = _isYTMusicQuickPicksItems(items);
|
||||
return ExploreSection(
|
||||
uri: json['uri'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
items: itemsList
|
||||
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
items: items,
|
||||
isYTMusicQuickPicks: isQuickPicks,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +128,17 @@ String _getLocalGreeting() {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||
if (items.isEmpty) return false;
|
||||
if (items.first.providerId != 'ytmusic-spotiflac') return false;
|
||||
for (final item in items) {
|
||||
if (item.type != 'track') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Provider for explore/home feed state
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
@override
|
||||
|
||||
@@ -100,6 +100,8 @@ class RecentAccessState {
|
||||
|
||||
/// Provider for managing recent access history
|
||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
RecentAccessState build() {
|
||||
_loadHistory();
|
||||
@@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final json = prefs.getString(_recentAccessKey);
|
||||
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
||||
|
||||
@@ -132,13 +134,13 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
|
||||
Future<void> _saveHistory() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_recentAccessKey, json);
|
||||
}
|
||||
|
||||
Future<void> _saveHiddenDownloads() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 1;
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
_loadSettings();
|
||||
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
@@ -46,7 +48,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('StoreProvider');
|
||||
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||
|
||||
/// Compare two semantic version strings
|
||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
|
||||
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
|
||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
|
||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
|
||||
|
||||
/// Notifier for managing theme settings with persistence
|
||||
class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
ThemeSettings build() {
|
||||
// Load settings asynchronously on first access
|
||||
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
/// Load theme settings from SharedPreferences
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final modeString = prefs.getString(kThemeModeKey);
|
||||
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
||||
final seedColor = prefs.getInt(kSeedColorKey);
|
||||
@@ -40,7 +42,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
/// Save current settings to SharedPreferences
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
||||
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
||||
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
||||
|
||||
+158
-100
@@ -29,6 +29,18 @@ class HomeTab extends ConsumerStatefulWidget {
|
||||
ConsumerState<HomeTab> createState() => _HomeTabState();
|
||||
}
|
||||
|
||||
class _RecentAccessView {
|
||||
final List<RecentAccessItem> uniqueItems;
|
||||
final List<RecentAccessItem> downloadItems;
|
||||
final bool hasHiddenDownloads;
|
||||
|
||||
const _RecentAccessView({
|
||||
required this.uniqueItems,
|
||||
required this.downloadItems,
|
||||
required this.hasHiddenDownloads,
|
||||
});
|
||||
}
|
||||
|
||||
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||
final _urlController = TextEditingController();
|
||||
bool _isTyping = false;
|
||||
@@ -51,6 +63,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
/// Debounce duration for live search
|
||||
static const Duration _liveSearchDelay = Duration(milliseconds: 800);
|
||||
|
||||
List<DownloadHistoryItem>? _recentAccessHistoryCache;
|
||||
List<RecentAccessItem>? _recentAccessItemsCache;
|
||||
Set<String>? _recentAccessHiddenIdsCache;
|
||||
_RecentAccessView? _recentAccessViewCache;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -447,7 +464,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
|
||||
final exploreState = ref.watch(exploreProvider);
|
||||
final exploreSections =
|
||||
ref.watch(exploreProvider.select((s) => s.sections));
|
||||
final exploreGreeting =
|
||||
ref.watch(exploreProvider.select((s) => s.greeting));
|
||||
final exploreLoading =
|
||||
ref.watch(exploreProvider.select((s) => s.isLoading));
|
||||
final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) =>
|
||||
s.extensions.any((e) => e.enabled && e.hasHomeFeed)
|
||||
));
|
||||
@@ -461,11 +483,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final topPadding = mediaQuery.padding.top;
|
||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
|
||||
final hiddenDownloadIds =
|
||||
ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds));
|
||||
|
||||
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
|
||||
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
|
||||
final recentAccessView = showRecentAccess
|
||||
? _getRecentAccessView(recentAccessItems, historyItems, hiddenDownloadIds)
|
||||
: null;
|
||||
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent;
|
||||
final hasExploreContent = exploreSections.isNotEmpty;
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent;
|
||||
|
||||
if (hasActualResults && isShowingRecentAccess) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -577,11 +605,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
if (showRecentAccess)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildRecentAccess(
|
||||
recentAccessItems,
|
||||
historyItems,
|
||||
colorScheme,
|
||||
),
|
||||
child: _buildRecentAccess(recentAccessView!, colorScheme),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
@@ -614,9 +638,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
|
||||
if (showExplore)
|
||||
..._buildExploreSections(exploreState, colorScheme),
|
||||
..._buildExploreSections(exploreSections, exploreGreeting, colorScheme),
|
||||
|
||||
if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading)
|
||||
if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
@@ -640,7 +664,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
Widget _buildRecentDownloads(List<DownloadHistoryItem> items, ColorScheme colorScheme) {
|
||||
final displayItems = items.take(10).toList();
|
||||
final itemCount = items.length < 10 ? items.length : 10;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -658,9 +682,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
height: 130,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: displayItems.length,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
final item = displayItems[index];
|
||||
final item = items[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: GestureDetector(
|
||||
@@ -711,10 +735,117 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) {
|
||||
final greeting = exploreState.greeting;
|
||||
_RecentAccessView _getRecentAccessView(
|
||||
List<RecentAccessItem> items,
|
||||
List<DownloadHistoryItem> historyItems,
|
||||
Set<String> hiddenIds,
|
||||
) {
|
||||
final cached = _recentAccessViewCache;
|
||||
if (cached != null &&
|
||||
identical(historyItems, _recentAccessHistoryCache) &&
|
||||
identical(items, _recentAccessItemsCache) &&
|
||||
identical(hiddenIds, _recentAccessHiddenIdsCache)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
||||
for (final h in historyItems) {
|
||||
final artistForKey =
|
||||
(h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||
? h.albumArtist!
|
||||
: h.artistName;
|
||||
final albumKey = '${h.albumName}|$artistForKey';
|
||||
albumGroups.putIfAbsent(albumKey, () => []).add(h);
|
||||
}
|
||||
|
||||
final downloadItems = <RecentAccessItem>[];
|
||||
for (final entry in albumGroups.entries) {
|
||||
final tracks = entry.value;
|
||||
final mostRecent = tracks.reduce(
|
||||
(a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b,
|
||||
);
|
||||
final artistForKey =
|
||||
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
||||
? mostRecent.albumArtist!
|
||||
: mostRecent.artistName;
|
||||
|
||||
if (tracks.length == 1) {
|
||||
downloadItems.add(
|
||||
RecentAccessItem(
|
||||
id: mostRecent.spotifyId ?? mostRecent.id,
|
||||
name: mostRecent.trackName,
|
||||
subtitle: mostRecent.artistName,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
downloadItems.add(
|
||||
RecentAccessItem(
|
||||
id: '${mostRecent.albumName}|$artistForKey',
|
||||
name: mostRecent.albumName,
|
||||
subtitle: artistForKey,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.album,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
|
||||
final visibleDownloads = <RecentAccessItem>[];
|
||||
for (final item in downloadItems) {
|
||||
if (!hiddenIds.contains(item.id)) {
|
||||
visibleDownloads.add(item);
|
||||
if (visibleDownloads.length >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final allItems = <RecentAccessItem>[
|
||||
...items,
|
||||
...visibleDownloads,
|
||||
];
|
||||
allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
|
||||
final seen = <String>{};
|
||||
final uniqueItems = <RecentAccessItem>[];
|
||||
for (final item in allItems) {
|
||||
final key = '${item.type.name}:${item.id}';
|
||||
if (seen.add(key)) {
|
||||
uniqueItems.add(item);
|
||||
if (uniqueItems.length >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final view = _RecentAccessView(
|
||||
uniqueItems: uniqueItems,
|
||||
downloadItems: downloadItems,
|
||||
hasHiddenDownloads: hiddenIds.isNotEmpty,
|
||||
);
|
||||
|
||||
_recentAccessHistoryCache = historyItems;
|
||||
_recentAccessItemsCache = items;
|
||||
_recentAccessHiddenIdsCache = hiddenIds;
|
||||
_recentAccessViewCache = view;
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
List<Widget> _buildExploreSections(
|
||||
List<ExploreSection> sections,
|
||||
String? greeting,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final hasGreeting = greeting != null && greeting.isNotEmpty;
|
||||
final sections = exploreState.sections;
|
||||
final sectionOffset = hasGreeting ? 1 : 0;
|
||||
final totalCount = sections.length + sectionOffset + 1; // + bottom padding
|
||||
|
||||
@@ -749,9 +880,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
|
||||
final isYTMusicQuickPicks = _isYTMusicQuickPicksSection(section);
|
||||
|
||||
if (isYTMusicQuickPicks) {
|
||||
if (section.isYTMusicQuickPicks) {
|
||||
return _buildYTMusicQuickPicksSection(section, colorScheme);
|
||||
}
|
||||
|
||||
@@ -783,19 +912,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
bool _isYTMusicQuickPicksSection(ExploreSection section) {
|
||||
if (section.items.isEmpty) return false;
|
||||
if (section.items.first.providerId != 'ytmusic-spotiflac') return false;
|
||||
|
||||
for (final item in section.items) {
|
||||
if (item.type != 'track') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Build YT Music "Quick picks" style swipeable pages section
|
||||
Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) {
|
||||
const itemsPerPage = 5;
|
||||
@@ -1097,72 +1213,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRecentAccess(
|
||||
List<RecentAccessItem> items,
|
||||
List<DownloadHistoryItem> historyItems,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
||||
for (final h in historyItems) {
|
||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||
? h.albumArtist!
|
||||
: h.artistName;
|
||||
final albumKey = '${h.albumName}|$artistForKey';
|
||||
albumGroups.putIfAbsent(albumKey, () => []).add(h);
|
||||
}
|
||||
|
||||
final downloadItems = <RecentAccessItem>[];
|
||||
for (final entry in albumGroups.entries) {
|
||||
final tracks = entry.value;
|
||||
final mostRecent = tracks.reduce((a, b) =>
|
||||
a.downloadedAt.isAfter(b.downloadedAt) ? a : b);
|
||||
final artistForKey = (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
||||
? mostRecent.albumArtist!
|
||||
: mostRecent.artistName;
|
||||
|
||||
if (tracks.length == 1) {
|
||||
downloadItems.add(RecentAccessItem(
|
||||
id: mostRecent.spotifyId ?? mostRecent.id,
|
||||
name: mostRecent.trackName,
|
||||
subtitle: mostRecent.artistName,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
));
|
||||
} else {
|
||||
downloadItems.add(RecentAccessItem(
|
||||
id: '${mostRecent.albumName}|$artistForKey',
|
||||
name: mostRecent.albumName,
|
||||
subtitle: artistForKey,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.album,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
|
||||
final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds));
|
||||
final visibleDownloads = downloadItems
|
||||
.where((item) => !hiddenIds.contains(item.id))
|
||||
.take(10)
|
||||
.toList();
|
||||
|
||||
final allItems = [...items, ...visibleDownloads];
|
||||
allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
|
||||
final seen = <String>{};
|
||||
final uniqueItems = allItems.where((item) {
|
||||
final key = '${item.type.name}:${item.id}';
|
||||
if (seen.contains(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).take(10).toList();
|
||||
|
||||
final hasHiddenDownloads = hiddenIds.isNotEmpty;
|
||||
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
||||
final uniqueItems = view.uniqueItems;
|
||||
final downloadItems = view.downloadItems;
|
||||
final hasHiddenDownloads = view.hasHiddenDownloads;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -2923,11 +2977,15 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
|
||||
},
|
||||
itemBuilder: (context, pageIndex) {
|
||||
final startIndex = pageIndex * widget.itemsPerPage;
|
||||
final endIndex = (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length);
|
||||
final pageItems = widget.section.items.sublist(startIndex, endIndex);
|
||||
final endIndex =
|
||||
(startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length);
|
||||
final pageItemCount = endIndex - startIndex;
|
||||
|
||||
return Column(
|
||||
children: pageItems.map((item) => _buildQuickPickItem(item)).toList(),
|
||||
children: List.generate(pageItemCount, (index) {
|
||||
final item = widget.section.items[startIndex + index];
|
||||
return _buildQuickPickItem(item);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -19,6 +19,14 @@ class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
static final RegExp _platformExceptionPattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
|
||||
static final RegExp _platformExceptionSimplePattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
|
||||
static final RegExp _trailingNullsPattern =
|
||||
RegExp(r',\s*null\s*,\s*null\)?$');
|
||||
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -296,19 +304,19 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
String message = error;
|
||||
|
||||
if (message.contains('PlatformException')) {
|
||||
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
||||
final match = _platformExceptionPattern.firstMatch(message);
|
||||
if (match != null) {
|
||||
message = match.group(1)?.trim() ?? message;
|
||||
} else {
|
||||
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
||||
final simpleMatch = _platformExceptionSimplePattern.firstMatch(message);
|
||||
if (simpleMatch != null) {
|
||||
message = simpleMatch.group(1)?.trim() ?? message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
||||
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
||||
message = message.replaceAll(_trailingNullsPattern, '');
|
||||
message = message.replaceAll(_leadingCommaPattern, '');
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
final RegExp _domainPattern =
|
||||
RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false);
|
||||
|
||||
class LogScreen extends StatefulWidget {
|
||||
const LogScreen({super.key});
|
||||
|
||||
@@ -13,6 +16,7 @@ class LogScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LogScreenState extends State<LogScreen> {
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _selectedLevel = 'ALL';
|
||||
@@ -633,7 +637,7 @@ class _LogSummaryCard extends StatelessWidget {
|
||||
combined.contains('connection refused')) {
|
||||
hasISPBlocking = true;
|
||||
|
||||
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
||||
final domainMatch = _domainPattern.firstMatch(combined);
|
||||
if (domainMatch != null) {
|
||||
blockedDomains.add(domainMatch.group(1)!);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static final RegExp _lrcTimestampPattern =
|
||||
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
||||
static final RegExp _lrcMetadataPattern =
|
||||
RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
||||
static const List<String> _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
@@ -1045,14 +1047,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final lines = lrc.split('\n');
|
||||
final cleanLines = <String>[];
|
||||
|
||||
// Pattern to match LRC metadata tags like [ti:...], [ar:...], [al:...], [by:...], etc.
|
||||
final metadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmedLine = line.trim();
|
||||
|
||||
// Skip metadata tags
|
||||
if (metadataPattern.hasMatch(trimmedLine)) {
|
||||
if (_lrcMetadataPattern.hasMatch(trimmedLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
class CsvImportService {
|
||||
static final _log = AppLogger('CsvImportService');
|
||||
static final RegExp _lineSplitPattern = RegExp(r'\r\n|\r|\n');
|
||||
|
||||
static Future<List<Track>> pickAndParseCsv({
|
||||
void Function(int current, int total)? onProgress,
|
||||
@@ -123,7 +124,7 @@ class CsvImportService {
|
||||
|
||||
static List<Track> _parseCsv(String content) {
|
||||
final List<Track> tracks = [];
|
||||
final lines = content.split(RegExp(r'\r\n|\r|\n'));
|
||||
final lines = content.split(_lineSplitPattern);
|
||||
if (lines.isEmpty) return tracks;
|
||||
|
||||
int startIdx = 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('HistoryDatabase');
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
/// Cached current iOS container path for path normalization
|
||||
String? _currentContainerPath;
|
||||
@@ -135,7 +136,7 @@ class HistoryDatabase {
|
||||
await _initContainerPath();
|
||||
if (_currentContainerPath == null) return false;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final lastContainer = prefs.getString('ios_last_container_path');
|
||||
|
||||
// Skip if container hasn't changed
|
||||
@@ -152,6 +153,7 @@ class HistoryDatabase {
|
||||
// Get all items with iOS paths
|
||||
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||
int updatedCount = 0;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String;
|
||||
@@ -160,7 +162,7 @@ class HistoryDatabase {
|
||||
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||
final newPath = _normalizeIosPath(oldPath);
|
||||
if (newPath != oldPath) {
|
||||
await db.update(
|
||||
batch.update(
|
||||
'history',
|
||||
{'file_path': newPath},
|
||||
where: 'id = ?',
|
||||
@@ -171,6 +173,10 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
// Save current container path
|
||||
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||
|
||||
@@ -185,7 +191,7 @@ class HistoryDatabase {
|
||||
/// 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 prefs = await _prefs;
|
||||
final migrationKey = 'history_migrated_to_sqlite';
|
||||
|
||||
if (prefs.getBool(migrationKey) == true) {
|
||||
|
||||
@@ -19,8 +19,9 @@ class PaletteService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_colorCache.containsKey(imageUrl)) {
|
||||
return _colorCache[imageUrl];
|
||||
final cached = _colorCache[imageUrl];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,12 @@ class ShareIntentService {
|
||||
factory ShareIntentService() => _instance;
|
||||
ShareIntentService._internal();
|
||||
|
||||
static final RegExp _spotifyUriPattern =
|
||||
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
|
||||
static final RegExp _spotifyUrlPattern = RegExp(
|
||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||
);
|
||||
|
||||
final _sharedUrlController = StreamController<String>.broadcast();
|
||||
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
|
||||
bool _initialized = false;
|
||||
@@ -57,14 +63,12 @@ class ShareIntentService {
|
||||
String? _extractSpotifyUrl(String text) {
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
|
||||
final uriMatch = _spotifyUriPattern.firstMatch(text);
|
||||
if (uriMatch != null) {
|
||||
return uriMatch.group(0);
|
||||
}
|
||||
|
||||
final urlMatch = RegExp(
|
||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||
).firstMatch(text);
|
||||
final urlMatch = _spotifyUrlPattern.firstMatch(text);
|
||||
if (urlMatch != null) {
|
||||
final fullUrl = urlMatch.group(0)!;
|
||||
final queryIndex = fullUrl.indexOf('?');
|
||||
|
||||
@@ -159,15 +159,17 @@ class LogBuffer extends ChangeNotifier {
|
||||
}
|
||||
|
||||
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
||||
final tagLower = tag?.toLowerCase();
|
||||
final searchLower = search?.toLowerCase();
|
||||
|
||||
return _entries.where((entry) {
|
||||
if (level != null && level != 'ALL' && entry.level != level) {
|
||||
return false;
|
||||
}
|
||||
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) {
|
||||
if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) {
|
||||
return false;
|
||||
}
|
||||
if (search != null && search.isNotEmpty) {
|
||||
final searchLower = search.toLowerCase();
|
||||
if (searchLower != null && searchLower.isNotEmpty) {
|
||||
return entry.message.toLowerCase().contains(searchLower) ||
|
||||
entry.tag.toLowerCase().contains(searchLower) ||
|
||||
(entry.error?.toLowerCase().contains(searchLower) ?? false);
|
||||
|
||||
@@ -26,6 +26,15 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
bool _isDownloading = false;
|
||||
double _progress = 0;
|
||||
String _statusText = '';
|
||||
static final RegExp _whatsNewPattern =
|
||||
RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false);
|
||||
static final RegExp _cutoffPattern =
|
||||
RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false);
|
||||
static final RegExp _sectionPattern = RegExp(r'^#{1,3}\s*(.+)$');
|
||||
static final RegExp _listPattern = RegExp(r'^[-*]\s+(.+)$');
|
||||
static final RegExp _subListPattern = RegExp(r'^\s+[-*]\s+(.+)$');
|
||||
static final RegExp _boldPattern = RegExp(r'\*\*([^*]+)\*\*');
|
||||
static final RegExp _codePattern = RegExp(r'`([^`]+)`');
|
||||
|
||||
Future<void> _downloadAndInstall() async {
|
||||
final apkUrl = widget.updateInfo.apkDownloadUrl;
|
||||
@@ -293,12 +302,12 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
String _formatChangelog(String changelog) {
|
||||
var content = changelog;
|
||||
|
||||
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
|
||||
final whatsNewMatch = _whatsNewPattern.firstMatch(content);
|
||||
if (whatsNewMatch != null) {
|
||||
content = content.substring(whatsNewMatch.end);
|
||||
}
|
||||
|
||||
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
|
||||
final cutoffMatch = _cutoffPattern.firstMatch(content);
|
||||
if (cutoffMatch != null) {
|
||||
content = content.substring(0, cutoffMatch.start);
|
||||
}
|
||||
@@ -310,7 +319,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
line = line.trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||
final sectionMatch = _sectionPattern.firstMatch(line);
|
||||
if (sectionMatch != null) {
|
||||
final section = sectionMatch.group(1)?.trim();
|
||||
if (section != null && section.isNotEmpty) {
|
||||
@@ -320,19 +329,19 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
continue;
|
||||
}
|
||||
|
||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||
final listMatch = _listPattern.firstMatch(line);
|
||||
if (listMatch != null) {
|
||||
var itemText = listMatch.group(1) ?? '';
|
||||
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
|
||||
itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? '');
|
||||
itemText = itemText.replaceAllMapped(_codePattern, (m) => m.group(1) ?? '');
|
||||
formattedLines.add('• $itemText');
|
||||
continue;
|
||||
}
|
||||
|
||||
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
||||
final subListMatch = _subListPattern.firstMatch(line);
|
||||
if (subListMatch != null) {
|
||||
var itemText = subListMatch.group(1) ?? '';
|
||||
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||
itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? '');
|
||||
formattedLines.add(' - $itemText');
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user