diff --git a/CHANGELOG.md b/CHANGELOG.md index e98e8891..d57f4443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 80e3f9f4..37da93dc 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -180,9 +180,9 @@ class DownloadHistoryNotifier extends Notifier { /// 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 { final currentItems = state.items; final itemsById = {}; final itemIndexById = {}; + 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 = {}; @@ -600,15 +611,12 @@ class DownloadQueueNotifier extends Notifier { 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 { 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((_) {}); } } diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 3256542a..b4e4e718 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -55,21 +55,26 @@ class ExploreSection { final String uri; final String title; final List items; + final bool isYTMusicQuickPicks; const ExploreSection({ required this.uri, required this.title, required this.items, + this.isYTMusicQuickPicks = false, }); factory ExploreSection.fromJson(Map json) { final itemsList = json['items'] as List? ?? []; + final items = itemsList + .map((item) => ExploreItem.fromJson(item as Map)) + .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)) - .toList(), + items: items, + isYTMusicQuickPicks: isQuickPicks, ); } } @@ -123,6 +128,17 @@ String _getLocalGreeting() { } } +bool _isYTMusicQuickPicksItems(List 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 { @override diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index ab0b1466..0070cc95 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -100,6 +100,8 @@ class RecentAccessState { /// Provider for managing recent access history class RecentAccessNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + @override RecentAccessState build() { _loadHistory(); @@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier { } Future _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 { } Future _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 _saveHiddenDownloads() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1e7830f4..b7c9687f 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version'; const _currentMigrationVersion = 1; class SettingsNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + @override AppSettings build() { _loadSettings(); @@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier { } Future _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 { } Future _saveSettings() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_settingsKey, jsonEncode(state.toJson())); } diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index 6a314cab..3fdd824f 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -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; diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index f1a3e728..7c38b33c 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -10,6 +10,8 @@ final themeProvider = NotifierProvider(() { /// Notifier for managing theme settings with persistence class ThemeNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + @override ThemeSettings build() { // Load settings asynchronously on first access @@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier { /// Load theme settings from SharedPreferences Future _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 { /// Save current settings to SharedPreferences Future _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); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index adc5917c..ce3c6d5f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -29,6 +29,18 @@ class HomeTab extends ConsumerStatefulWidget { ConsumerState createState() => _HomeTabState(); } +class _RecentAccessView { + final List uniqueItems; + final List downloadItems; + final bool hasHiddenDownloads; + + const _RecentAccessView({ + required this.uniqueItems, + required this.downloadItems, + required this.hasHiddenDownloads, + }); +} + class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); bool _isTyping = false; @@ -51,6 +63,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient /// Debounce duration for live search static const Duration _liveSearchDelay = Duration(milliseconds: 800); + + List? _recentAccessHistoryCache; + List? _recentAccessItemsCache; + Set? _recentAccessHiddenIdsCache; + _RecentAccessView? _recentAccessViewCache; @override bool get wantKeepAlive => true; @@ -447,7 +464,12 @@ class _HomeTabState extends ConsumerState 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 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 with AutomaticKeepAliveClient if (showRecentAccess) SliverToBoxAdapter( - child: _buildRecentAccess( - recentAccessItems, - historyItems, - colorScheme, - ), + child: _buildRecentAccess(recentAccessView!, colorScheme), ), SliverToBoxAdapter( @@ -614,9 +638,9 @@ class _HomeTabState extends ConsumerState 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 with AutomaticKeepAliveClient } Widget _buildRecentDownloads(List 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 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 with AutomaticKeepAliveClient ); } - List _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) { - final greeting = exploreState.greeting; + _RecentAccessView _getRecentAccessView( + List items, + List historyItems, + Set hiddenIds, + ) { + final cached = _recentAccessViewCache; + if (cached != null && + identical(historyItems, _recentAccessHistoryCache) && + identical(items, _recentAccessItemsCache) && + identical(hiddenIds, _recentAccessHiddenIdsCache)) { + return cached; + } + + final albumGroups = >{}; + 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 = []; + 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 = []; + for (final item in downloadItems) { + if (!hiddenIds.contains(item.id)) { + visibleDownloads.add(item); + if (visibleDownloads.length >= 10) { + break; + } + } + } + + final allItems = [ + ...items, + ...visibleDownloads, + ]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final seen = {}; + final uniqueItems = []; + 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 _buildExploreSections( + List 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 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 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 with AutomaticKeepAliveClient } } - Widget _buildRecentAccess( - List items, - List historyItems, - ColorScheme colorScheme, - ) { - final albumGroups = >{}; - 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 = []; - 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 = {}; - 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); + }), ); }, ), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index d6308aa3..d979d5f9 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -19,6 +19,14 @@ class ExtensionsPage extends ConsumerStatefulWidget { } class _ExtensionsPageState extends ConsumerState { + 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 { 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; } diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index efe95a08..69ce1c62 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -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 { + 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)!); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 41f3e7f6..53ceec2d 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -37,6 +37,8 @@ class _TrackMetadataScreenState extends ConsumerState { 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 _months = [ 'Jan', 'Feb', @@ -1045,14 +1047,11 @@ class _TrackMetadataScreenState extends ConsumerState { final lines = lrc.split('\n'); final cleanLines = []; - // 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; } diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 0687f385..49a4744a 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -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> pickAndParseCsv({ void Function(int current, int total)? onProgress, @@ -123,7 +124,7 @@ class CsvImportService { static List _parseCsv(String content) { final List tracks = []; - final lines = content.split(RegExp(r'\r\n|\r|\n')); + final lines = content.split(_lineSplitPattern); if (lines.isEmpty) return tracks; int startIdx = 0; diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 1cf1088d..de24ab6b 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('HistoryDatabase'); +final Future _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 migrateFromSharedPreferences() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final migrationKey = 'history_migrated_to_sqlite'; if (prefs.getBool(migrationKey) == true) { diff --git a/lib/services/palette_service.dart b/lib/services/palette_service.dart index 4c5ab61c..045a83a9 100644 --- a/lib/services/palette_service.dart +++ b/lib/services/palette_service.dart @@ -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 { diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 36b032ec..fac3a9a6 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -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.broadcast(); StreamSubscription>? _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('?'); diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 3e69757b..0208d070 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -159,15 +159,17 @@ class LogBuffer extends ChangeNotifier { } List 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); diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 63be0b0c..f46c4d04 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -26,6 +26,15 @@ class _UpdateDialogState extends State { 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 _downloadAndInstall() async { final apkUrl = widget.updateInfo.apkDownloadUrl; @@ -293,12 +302,12 @@ class _UpdateDialogState extends State { 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 { 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 { 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; }