perf: reduce bridge and UI churn

This commit is contained in:
zarzet
2026-05-03 14:12:53 +07:00
parent b329acd710
commit 34894faabf
24 changed files with 2451 additions and 494 deletions
+15 -2
View File
@@ -213,6 +213,7 @@ List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
class ExploreNotifier extends Notifier<ExploreState> {
static const _cacheKey = 'explore_home_feed_cache';
static const _cacheTsKey = 'explore_home_feed_ts';
int _homeFeedRequestId = 0;
@override
ExploreState build() {
@@ -281,6 +282,8 @@ class ExploreNotifier extends Notifier<ExploreState> {
if (ref.read(settingsProvider).homeFeedProvider ==
AppSettings.homeFeedProviderOff) {
_log.d('Home feed disabled by user setting');
_homeFeedRequestId++;
PlatformBridge.cancelExtensionHomeFeedRequests();
state = const ExploreState();
return;
}
@@ -293,11 +296,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
return;
}
if (state.isLoading) {
if (state.isLoading && !forceRefresh) {
_log.d('Home feed fetch already in progress');
return;
}
final requestId = ++_homeFeedRequestId;
final showLoading = !state.hasContent;
state = state.copyWith(isLoading: showLoading, error: null);
@@ -330,6 +334,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
if (requestId != _homeFeedRequestId) return;
state = state.copyWith(
isLoading: false,
error: 'No extension with home feed support enabled',
@@ -338,7 +343,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
final result = await PlatformBridge.getExtensionHomeFeed(
targetExt.id,
cancelPrevious: forceRefresh,
);
if (requestId != _homeFeedRequestId) return;
if (result == null) {
state = state.copyWith(
@@ -362,6 +371,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_normalizeExploreSectionsPayload,
sectionsData,
);
if (requestId != _homeFeedRequestId) return;
final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
);
@@ -388,11 +398,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
_saveToCache(normalizedSections);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
if (requestId != _homeFeedRequestId) return;
state = state.copyWith(isLoading: false, error: e.toString());
}
}
void clear() {
_homeFeedRequestId++;
PlatformBridge.cancelExtensionHomeFeedRequests();
state = const ExploreState();
}
+11 -2
View File
@@ -15,6 +15,15 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
const _spotifyWebExtensionId = 'spotify-web';
bool _stringListEquals(List<String> a, List<String> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
class BuiltInProviderSpec {
final String id;
final String displayName;
@@ -1033,7 +1042,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
if (_stringListEquals(sanitized, state.providerPriority)) {
return;
}
@@ -1053,7 +1062,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state.metadataProviderPriority,
);
final sanitized = _sanitizeMetadataProviderPriority(replaced);
if (jsonEncode(sanitized) == jsonEncode(state.metadataProviderPriority)) {
if (_stringListEquals(sanitized, state.metadataProviderPriority)) {
return;
}
+1
View File
@@ -778,6 +778,7 @@ class TrackNotifier extends Notifier<TrackState> {
extensionId,
query,
options: options,
cancelPrevious: true,
);
if (!_isRequestValid(requestId)) {
+24 -32
View File
@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -24,6 +22,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -1164,12 +1163,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
fit: StackFit.expand,
children: [
if (hasValidImage)
CachedNetworkImage(
CachedCoverImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
memCacheWidth: 800,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) =>
Container(color: colorScheme.surfaceContainerHighest),
errorWidget: (context, url, error) => Container(
@@ -1477,33 +1475,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const SizedBox(width: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: track.coverUrl != null
? CachedNetworkImage(
imageUrl: track.coverUrl!,
track.coverUrl != null
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(4),
placeholder: (context, url) => Container(
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
@@ -1513,7 +1496,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
size: 24,
),
),
),
)
: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -1801,13 +1794,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: album.coverUrl!,
width: tileSize,
height: tileSize,
fit: BoxFit.cover,
memCacheWidth: (tileSize * 2).round(),
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
width: tileSize,
height: tileSize,
+35 -28
View File
@@ -4,8 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -33,6 +31,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
part 'home_tab_helpers.dart';
part 'home_tab_widgets.dart';
@@ -1182,7 +1181,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
final mediaQuery = MediaQuery.of(context);
final screenHeight = mediaQuery.size.height;
final topPadding = normalizedHeaderTopPadding(context);
final historyItems = ref.watch(_homeHistoryPreviewProvider);
final hasHistoryItems = ref.watch(
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
);
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
final showRecentAccess =
@@ -1212,7 +1213,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
!hasHomeFeedExtension &&
!hasExploreContent &&
!hasResults &&
historyItems.isEmpty;
!hasHistoryItems;
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
@@ -1393,18 +1394,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
),
if (historyItems.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(
24,
32,
24,
24,
),
child: _buildRecentDownloads(
historyItems,
colorScheme,
),
if (hasHistoryItems)
Consumer(
builder: (context, ref, _) {
final historyItems = ref.watch(
_homeHistoryPreviewProvider,
);
return Padding(
padding: const EdgeInsets.fromLTRB(
24,
32,
24,
24,
),
child: _buildRecentDownloads(
historyItems,
colorScheme,
),
);
},
),
],
),
@@ -1717,7 +1725,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
final sectionIndex = index - sectionOffset;
if (sectionIndex < sections.length) {
return _buildExploreSection(sections[sectionIndex], colorScheme);
final section = sections[sectionIndex];
return KeyedSubtree(
key: ValueKey('explore-section-${section.uri}-${section.title}'),
child: _buildExploreSection(section, colorScheme),
);
}
return const SizedBox(height: 24);
@@ -1753,6 +1765,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
itemBuilder: (context, index) {
final item = section.items[index];
return StaggeredListItem(
key: ValueKey(
'explore-item-${item.type}-${item.id}-${item.uri}',
),
index: index,
staggerDelay: const Duration(milliseconds: 50),
child: _buildExploreItem(item, colorScheme),
@@ -1806,14 +1821,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
isArtist ? cardSize / 2 : 10,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
memCacheWidth: (cardSize * 2).round(),
memCacheHeight: (cardSize * 2).round(),
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
width: cardSize,
height: cardSize,
@@ -1968,13 +1980,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 64,
height: 64,
fit: BoxFit.cover,
memCacheWidth: 128,
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 64,
@@ -2474,10 +2484,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
precacheImage(
ResizeImage(
CachedNetworkImageProvider(
url,
cacheManager: CoverCacheManager.instance,
),
cachedCoverImageProvider(url),
width: targetSize,
height: targetSize,
),
+20 -30
View File
@@ -25,8 +25,12 @@ class _SearchProviderDropdown extends ConsumerWidget {
final rawCurrentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final extensionState = ref.watch(extensionProvider);
final extensions = extensionState.extensions;
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final providerReadiness = ref.watch(
extensionProvider.select(
(s) => (isInitialized: s.isInitialized, error: s.error),
),
);
final colorScheme = Theme.of(context).colorScheme;
final searchProviders = extensions
@@ -36,7 +40,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
final hasAnyProvider =
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
final isProviderLoading =
!extensionState.isInitialized && extensionState.error == null;
!providerReadiness.isInitialized && providerReadiness.error == null;
if (!hasAnyProvider) {
return Padding(
@@ -324,14 +328,11 @@ class _TrackItemWithStatus extends ConsumerWidget {
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: track.coverUrl != null
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: thumbWidth,
height: thumbHeight,
fit: BoxFit.cover,
memCacheWidth: (thumbWidth * 2).toInt(),
memCacheHeight: (thumbHeight * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
width: thumbWidth,
@@ -518,14 +519,11 @@ class _CollectionItemWidget extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 56,
@@ -623,14 +621,11 @@ class _SearchArtistItemWidget extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(28),
child: hasValidImage
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: artist.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 56,
@@ -724,14 +719,11 @@ class _SearchAlbumItemWidget extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: hasValidImage
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: album.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 56,
@@ -828,14 +820,11 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: hasValidImage
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: playlist.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 56,
@@ -996,14 +985,13 @@ class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> {
errorBuilder: (_, _, _) => _fallback(),
);
} else if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) {
child = CachedNetworkImage(
child = CachedCoverImage(
imageUrl: widget.imageUrl!,
width: widget.width,
height: widget.height,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
memCacheHeight: cacheHeight,
cacheManager: CoverCacheManager.instance,
errorWidget: (_, _, _) => _fallback(),
);
} else {
@@ -1617,7 +1605,12 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
return Column(
children: List.generate(pageItemCount, (index) {
final item = widget.section.items[startIndex + index];
return _buildQuickPickItem(item);
return KeyedSubtree(
key: ValueKey(
'quick-pick-${item.type}-${item.id}-${item.uri}',
),
child: _buildQuickPickItem(item),
);
}),
);
},
@@ -1661,14 +1654,11 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
+6 -13
View File
@@ -1,7 +1,5 @@
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/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -19,6 +17,7 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
@@ -297,11 +296,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
fit: StackFit.expand,
children: [
if (_coverUrl != null)
CachedNetworkImage(
CachedCoverImage(
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
@@ -838,16 +836,11 @@ class _PlaylistTrackItem extends ConsumerWidget {
borderRadius: BorderRadius.circular(12),
),
leading: track.coverUrl != null
? ClipRRect(
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
+22 -42
View File
@@ -4,10 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -31,6 +29,7 @@ import 'package:spotiflac_android/screens/favorite_artists_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
@@ -1841,10 +1840,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
precacheImage(
ResizeImage(
CachedNetworkImageProvider(
url,
cacheManager: CoverCacheManager.instance,
),
cachedCoverImageProvider(url),
width: targetSize,
height: targetSize,
),
@@ -2166,18 +2162,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
}
return ClipRRect(
return CachedCoverImage(
imageUrl: firstCoverUrl,
width: size,
height: size,
memCacheWidth: cacheExtent,
borderRadius: borderRadius,
child: CachedNetworkImage(
imageUrl: firstCoverUrl,
width: size,
height: size,
fit: BoxFit.cover,
memCacheWidth: cacheExtent,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
),
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
);
}
@@ -3799,14 +3791,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_albumPlaceholder(colorScheme),
)
: album.coverUrl != null
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: album.coverUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 300,
memCacheHeight: 300,
cacheManager: CoverCacheManager.instance,
)
: null,
badgeColor: colorScheme.primaryContainer,
@@ -5383,20 +5374,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
final coverSize = _queueCoverSize();
final memCacheSize = (coverSize * 2).round();
return item.track.coverUrl != null
? ClipRRect(
? CachedCoverImage(
imageUrl: item.track.coverUrl!,
width: coverSize,
height: coverSize,
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
memCacheWidth: memCacheSize,
memCacheHeight: memCacheSize,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: coverSize,
@@ -5651,19 +5635,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
if (item.coverUrl != null) {
return ClipRRect(
return CachedCoverImage(
imageUrl: item.coverUrl!,
width: size,
height: size,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: size,
height: size,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => buildPlaceholder(),
errorWidget: (context, url, error) => buildPlaceholder(),
),
placeholder: (context, url) => buildPlaceholder(),
errorWidget: (context, url, error) => buildPlaceholder(),
);
}
+87 -64
View File
@@ -1,8 +1,6 @@
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/l10n/l10n.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -12,6 +10,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
class SearchScreen extends ConsumerStatefulWidget {
final String query;
@@ -49,30 +48,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final service = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
if (service.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
@override
Widget build(BuildContext context) {
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final error = ref.watch(trackProvider.select((s) => s.error));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
@@ -98,36 +75,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
],
),
body: Column(
children: [
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
if (error != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
Expanded(
child: AnimatedStateSwitcher(
child: isLoading && tracks.isEmpty
? const TrackListSkeleton(key: ValueKey('loading'))
: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
key: const ValueKey('results'),
itemCount: tracks.length,
itemBuilder: (context, index) => StaggeredListItem(
index: index,
child: _buildTrackTile(tracks[index], colorScheme),
),
),
),
),
],
),
body: const _SearchResultsBody(),
);
}
}
Widget _buildEmptyState(ColorScheme colorScheme) {
class _SearchResultsBody extends ConsumerWidget {
const _SearchResultsBody();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final error = ref.watch(trackProvider.select((s) => s.error));
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
if (error != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
Expanded(
child: AnimatedStateSwitcher(
child: isLoading && tracks.isEmpty
? const TrackListSkeleton(key: ValueKey('loading'))
: tracks.isEmpty
? _SearchEmptyState(
key: const ValueKey('empty'),
colorScheme: colorScheme,
)
: ListView.builder(
key: const ValueKey('results'),
itemCount: tracks.length,
itemBuilder: (context, index) => StaggeredListItem(
key: ValueKey('search-track-${tracks[index].id}-$index'),
index: index,
child: _SearchTrackTile(track: tracks[index]),
),
),
),
),
],
);
}
}
class _SearchEmptyState extends StatelessWidget {
final ColorScheme colorScheme;
const _SearchEmptyState({super.key, required this.colorScheme});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -144,20 +146,41 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
);
}
}
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
class _SearchTrackTile extends ConsumerWidget {
final Track track;
const _SearchTrackTile({required this.track});
void _downloadTrack(BuildContext context, WidgetRef ref) {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final service = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
if (service.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
);
return;
}
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final coverWidget = track.coverUrl != null
? ClipRRect(
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
@@ -218,11 +241,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
IconButton(
icon: const Icon(Icons.download_rounded),
tooltip: context.l10n.dialogDownload,
onPressed: () => _downloadTrack(track),
onPressed: () => _downloadTrack(context, ref),
),
],
),
onTap: () => _downloadTrack(track),
onTap: () => _downloadTrack(context, ref),
);
}
}
+579 -42
View File
@@ -1,19 +1,61 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PlatformBridge');
class _BridgeCacheEntry {
final Map<String, dynamic> value;
final DateTime expiresAt;
const _BridgeCacheEntry({required this.value, required this.expiresAt});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
class _BridgeInFlight<T> {
final String requestId;
final String scopeKey;
final Future<T> future;
const _BridgeInFlight({
required this.requestId,
required this.scopeKey,
required this.future,
});
}
class PlatformBridge {
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
static const _jsonResultFileKey = '__json_file';
static const _metadataCacheTtl = Duration(minutes: 20);
static const _availabilityCacheTtl = Duration(minutes: 15);
static const _bridgeCacheMaxEntries = 256;
static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1';
static const _availabilityPersistentCacheKey =
'bridge_availability_lookup_cache_v1';
static const _downloadProgressEvents = EventChannel(
'com.zarz.spotiflac/download_progress_stream',
);
static const _libraryScanProgressEvents = EventChannel(
'com.zarz.spotiflac/library_scan_progress_stream',
);
static final Map<String, _BridgeCacheEntry> _metadataCache = {};
static final Map<String, _BridgeCacheEntry> _availabilityCache = {};
static final Map<String, Future<Map<String, dynamic>>> _metadataInFlight = {};
static final Map<String, Future<Map<String, dynamic>>> _availabilityInFlight =
{};
static final Map<String, _BridgeInFlight<List<Map<String, dynamic>>>>
_customSearchInFlight = {};
static final Map<String, _BridgeInFlight<Map<String, dynamic>?>>
_homeFeedInFlight = {};
static Future<void>? _persistentLookupCacheLoadFuture;
static int _lookupCacheGeneration = 0;
static int _extensionRequestSequence = 0;
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
@@ -24,12 +66,324 @@ class PlatformBridge {
String spotifyId,
String isrc,
) async {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', {
'spotify_id': spotifyId,
'isrc': isrc,
final cacheKey = _availabilityCacheKey(spotifyId, isrc);
if (cacheKey.isEmpty) {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', {
'spotify_id': spotifyId,
'isrc': isrc,
});
return _decodeRequiredMapResult(result, 'checkAvailability');
}
await _ensurePersistentLookupCachesLoaded();
final cached = _getCachedMap(_availabilityCache, cacheKey);
if (cached != null) return cached;
final inFlight = _availabilityInFlight[cacheKey];
if (inFlight != null) return _copyStringMap(await inFlight);
final generation = _lookupCacheGeneration;
final future = _invokeCachedMap(
cacheKey,
_availabilityCache,
() async {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', {
'spotify_id': spotifyId,
'isrc': isrc,
});
return _decodeRequiredMapResult(result, 'checkAvailability');
},
_availabilityCacheTtl,
generation,
_availabilityPersistentCacheKey,
);
_availabilityInFlight[cacheKey] = future;
try {
return _copyStringMap(await future);
} finally {
_availabilityInFlight.remove(cacheKey);
}
}
static Future<Map<String, dynamic>> _invokeCachedMap(
String key,
Map<String, _BridgeCacheEntry> cache,
Future<Map<String, dynamic>> Function() loader,
Duration ttl,
int generation,
String persistentCacheKey,
) async {
final value = await loader();
if (generation == _lookupCacheGeneration) {
_putCachedMap(cache, key, value, ttl, persistentCacheKey);
}
return _copyStringMap(value);
}
static String _availabilityCacheKey(String spotifyId, String isrc) {
final normalizedIsrc = isrc.trim().toUpperCase();
if (normalizedIsrc.isNotEmpty) {
return 'isrc:$normalizedIsrc';
}
final normalizedSpotifyId = spotifyId.trim();
if (normalizedSpotifyId.isEmpty) return '';
return 'spotify:$normalizedSpotifyId';
}
static String _providerMetadataCacheKey(
String providerId,
String resourceType,
String resourceId,
) {
return [
providerId.trim().toLowerCase(),
resourceType.trim().toLowerCase(),
resourceId.trim(),
].join(':');
}
static Map<String, dynamic>? _getCachedMap(
Map<String, _BridgeCacheEntry> cache,
String key,
) {
_pruneExpiredBridgeCache(cache);
final entry = cache[key];
if (entry == null) return null;
if (entry.isExpired) {
cache.remove(key);
return null;
}
return _copyStringMap(entry.value);
}
static void _putCachedMap(
Map<String, _BridgeCacheEntry> cache,
String key,
Map<String, dynamic> value,
Duration ttl,
String persistentCacheKey,
) {
_pruneExpiredBridgeCache(cache);
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
cache.remove(cache.keys.first);
}
cache[key] = _BridgeCacheEntry(
value: _copyStringMap(value),
expiresAt: DateTime.now().add(ttl),
);
unawaited(
_persistLookupCache(cache, persistentCacheKey, _lookupCacheGeneration),
);
}
static void _pruneExpiredBridgeCache(Map<String, _BridgeCacheEntry> cache) {
if (cache.isEmpty) return;
final now = DateTime.now();
cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
}
static dynamic _copyJsonLike(dynamic value) {
if (value is Map) {
return <String, dynamic>{
for (final entry in value.entries)
entry.key.toString(): _copyJsonLike(entry.value),
};
}
if (value is List) {
return value.map(_copyJsonLike).toList(growable: false);
}
return value;
}
static Map<String, dynamic> _copyStringMap(Map<String, dynamic> value) {
return <String, dynamic>{
for (final entry in value.entries) entry.key: _copyJsonLike(entry.value),
};
}
static Map<String, dynamic>? _copyNullableStringMap(
Map<String, dynamic>? value,
) {
if (value == null) return null;
return _copyStringMap(value);
}
static List<Map<String, dynamic>> _copyMapList(
List<Map<String, dynamic>> value,
) {
return value.map(_copyStringMap).toList(growable: false);
}
static dynamic _canonicalizeJsonLike(dynamic value) {
if (value is Map) {
final entries = value.entries.toList()
..sort((a, b) => a.key.toString().compareTo(b.key.toString()));
return <String, dynamic>{
for (final entry in entries)
entry.key.toString(): _canonicalizeJsonLike(entry.value),
};
}
if (value is List) {
return value.map(_canonicalizeJsonLike).toList(growable: false);
}
return value;
}
static Future<void> _ensurePersistentLookupCachesLoaded() {
return _persistentLookupCacheLoadFuture ??= _loadPersistentLookupCaches(
_lookupCacheGeneration,
);
}
static Future<void> _loadPersistentLookupCaches(int generation) async {
try {
final prefs = await SharedPreferences.getInstance();
if (generation != _lookupCacheGeneration) return;
_restorePersistentCache(
prefs,
_metadataPersistentCacheKey,
_metadataCache,
);
_restorePersistentCache(
prefs,
_availabilityPersistentCacheKey,
_availabilityCache,
);
} catch (e) {
_log.w('Failed to load bridge lookup cache: $e');
}
}
static void _restorePersistentCache(
SharedPreferences prefs,
String prefsKey,
Map<String, _BridgeCacheEntry> target,
) {
final raw = prefs.getString(prefsKey);
if (raw == null || raw.isEmpty) return;
final decoded = jsonDecode(raw);
if (decoded is! Map) return;
final now = DateTime.now();
for (final entry in decoded.entries) {
if (target.length >= _bridgeCacheMaxEntries) break;
final key = entry.key.toString();
final rawEntry = entry.value;
if (key.isEmpty || rawEntry is! Map) continue;
final expiresAtMs = rawEntry['expires_at'];
final value = rawEntry['value'];
if (expiresAtMs is! int || value is! Map) continue;
final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs);
if (!expiresAt.isAfter(now)) continue;
target[key] = _BridgeCacheEntry(
value: _copyStringMap(Map<String, dynamic>.from(value)),
expiresAt: expiresAt,
);
}
}
static Future<void> _persistLookupCache(
Map<String, _BridgeCacheEntry> cache,
String prefsKey,
int generation,
) async {
try {
_pruneExpiredBridgeCache(cache);
final data = <String, dynamic>{
for (final entry in cache.entries)
entry.key: {
'expires_at': entry.value.expiresAt.millisecondsSinceEpoch,
'value': entry.value.value,
},
};
final prefs = await SharedPreferences.getInstance();
if (generation != _lookupCacheGeneration) return;
await prefs.setString(prefsKey, jsonEncode(data));
} catch (e) {
_log.w('Failed to persist bridge lookup cache: $e');
}
}
static Future<void> _clearPersistentLookupCaches() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_metadataPersistentCacheKey);
await prefs.remove(_availabilityPersistentCacheKey);
} catch (e) {
_log.w('Failed to clear bridge lookup cache: $e');
}
}
static Future<void> _clearLookupCaches() async {
_lookupCacheGeneration++;
_persistentLookupCacheLoadFuture = null;
_metadataCache.clear();
_availabilityCache.clear();
_metadataInFlight.clear();
_availabilityInFlight.clear();
for (final inFlight in _customSearchInFlight.values) {
_cancelExtensionRequestUnawaited(inFlight.requestId);
}
for (final inFlight in _homeFeedInFlight.values) {
_cancelExtensionRequestUnawaited(inFlight.requestId);
}
_customSearchInFlight.clear();
_homeFeedInFlight.clear();
await _clearPersistentLookupCaches();
}
static String _nextExtensionRequestId(String kind, String extensionId) {
_extensionRequestSequence++;
return [
kind,
DateTime.now().microsecondsSinceEpoch,
_extensionRequestSequence,
extensionId.trim(),
].join(':');
}
static void _cancelExtensionRequestUnawaited(String requestId) {
if (requestId.isEmpty) return;
unawaited(
cancelExtensionRequest(requestId).catchError((Object e) {
_log.w('Failed to cancel extension request $requestId: $e');
}),
);
}
static Future<void> cancelExtensionRequest(String requestId) async {
if (requestId.isEmpty) return;
await _channel.invokeMethod('cancelExtensionRequest', {
'request_id': requestId,
});
return _decodeRequiredMapResult(result, 'checkAvailability');
}
static void _cancelCustomSearchInFlightForScope(
String scopeKey, {
String? exceptKey,
}) {
for (final entry in _customSearchInFlight.entries.toList()) {
if (entry.key == exceptKey || entry.value.scopeKey != scopeKey) continue;
_cancelExtensionRequestUnawaited(entry.value.requestId);
}
}
static void cancelExtensionHomeFeedRequests() {
for (final inFlight in _homeFeedInFlight.values) {
_cancelExtensionRequestUnawaited(inFlight.requestId);
}
_homeFeedInFlight.clear();
}
static int _lookupCacheSize() {
_pruneExpiredBridgeCache(_metadataCache);
_pruneExpiredBridgeCache(_availabilityCache);
return _metadataCache.length + _availabilityCache.length;
}
static Future<Map<String, dynamic>> _invokeDownloadMethod(
@@ -485,11 +839,13 @@ class PlatformBridge {
}
static Future<int> getTrackCacheSize() async {
await _ensurePersistentLookupCachesLoaded();
final result = await _channel.invokeMethod('getTrackCacheSize');
return result as int;
return (result as int) + _lookupCacheSize();
}
static Future<void> clearTrackCache() async {
await _clearLookupCaches();
await _channel.invokeMethod('clearTrackCache');
}
@@ -533,17 +889,45 @@ class PlatformBridge {
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getProviderMetadata', {
'provider_id': providerId,
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getProviderMetadata returned null for $providerId:$resourceType:$resourceId',
);
final cacheKey = _providerMetadataCacheKey(
providerId,
resourceType,
resourceId,
);
await _ensurePersistentLookupCachesLoaded();
final cached = _getCachedMap(_metadataCache, cacheKey);
if (cached != null) return cached;
final inFlight = _metadataInFlight[cacheKey];
if (inFlight != null) return _copyStringMap(await inFlight);
final generation = _lookupCacheGeneration;
final future = _invokeCachedMap(
cacheKey,
_metadataCache,
() async {
final result = await _channel.invokeMethod('getProviderMetadata', {
'provider_id': providerId,
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getProviderMetadata returned null for $providerId:$resourceType:$resourceId',
);
}
return _decodeRequiredMapResult(result, 'getProviderMetadata');
},
_metadataCacheTtl,
generation,
_metadataPersistentCacheKey,
);
_metadataInFlight[cacheKey] = future;
try {
return _copyStringMap(await future);
} finally {
_metadataInFlight.remove(cacheKey);
}
return _decodeRequiredMapResult(result, 'getProviderMetadata');
}
static Future<Map<String, dynamic>> searchDeezerByISRC(
@@ -584,11 +968,39 @@ class PlatformBridge {
String resourceType,
String spotifyId,
) async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
'resource_type': resourceType,
'spotify_id': spotifyId,
});
return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer');
final cacheKey = _providerMetadataCacheKey(
'spotify-to-deezer',
resourceType,
spotifyId,
);
await _ensurePersistentLookupCachesLoaded();
final cached = _getCachedMap(_metadataCache, cacheKey);
if (cached != null) return cached;
final inFlight = _metadataInFlight[cacheKey];
if (inFlight != null) return _copyStringMap(await inFlight);
final generation = _lookupCacheGeneration;
final future = _invokeCachedMap(
cacheKey,
_metadataCache,
() async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
'resource_type': resourceType,
'spotify_id': spotifyId,
});
return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer');
},
_metadataCacheTtl,
generation,
_metadataPersistentCacheKey,
);
_metadataInFlight[cacheKey] = future;
try {
return _copyStringMap(await future);
} finally {
_metadataInFlight.remove(cacheKey);
}
}
static Future<List<Map<String, dynamic>>> getGoLogs() async {
@@ -641,6 +1053,7 @@ class PlatformBridge {
String filePath,
) async {
_log.d('loadExtensionFromPath: $filePath');
await _clearLookupCaches();
final result = await _channel.invokeMethod('loadExtensionFromPath', {
'file_path': filePath,
});
@@ -649,6 +1062,7 @@ class PlatformBridge {
static Future<void> unloadExtension(String extensionId) async {
_log.d('unloadExtension: $extensionId');
await _clearLookupCaches();
await _channel.invokeMethod('unloadExtension', {
'extension_id': extensionId,
});
@@ -656,6 +1070,7 @@ class PlatformBridge {
static Future<void> removeExtension(String extensionId) async {
_log.d('removeExtension: $extensionId');
await _clearLookupCaches();
await _channel.invokeMethod('removeExtension', {
'extension_id': extensionId,
});
@@ -663,6 +1078,7 @@ class PlatformBridge {
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
_log.d('upgradeExtension: $filePath');
await _clearLookupCaches();
final result = await _channel.invokeMethod('upgradeExtension', {
'file_path': filePath,
});
@@ -689,6 +1105,7 @@ class PlatformBridge {
bool enabled,
) async {
_log.d('setExtensionEnabled: $extensionId = $enabled');
await _clearLookupCaches();
await _channel.invokeMethod('setExtensionEnabled', {
'extension_id': extensionId,
'enabled': enabled,
@@ -697,6 +1114,7 @@ class PlatformBridge {
static Future<void> setProviderPriority(List<String> providerIds) async {
_log.d('setProviderPriority: $providerIds');
await _clearLookupCaches();
await _channel.invokeMethod('setProviderPriority', {
'priority': jsonEncode(providerIds),
});
@@ -711,6 +1129,7 @@ class PlatformBridge {
List<String>? extensionIds,
) async {
_log.d('setDownloadFallbackExtensionIds: $extensionIds');
await _clearLookupCaches();
await _channel.invokeMethod('setDownloadFallbackExtensionIds', {
'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds),
});
@@ -720,6 +1139,7 @@ class PlatformBridge {
List<String> providerIds,
) async {
_log.d('setMetadataProviderPriority: $providerIds');
await _clearLookupCaches();
await _channel.invokeMethod('setMetadataProviderPriority', {
'priority': jsonEncode(providerIds),
});
@@ -744,6 +1164,7 @@ class PlatformBridge {
Map<String, dynamic> settings,
) async {
_log.d('setExtensionSettings: $extensionId');
await _clearLookupCaches();
await _channel.invokeMethod('setExtensionSettings', {
'extension_id': extensionId,
'settings': jsonEncode(settings),
@@ -883,13 +1304,45 @@ class PlatformBridge {
String extensionId,
String query, {
Map<String, dynamic>? options,
bool cancelPrevious = false,
}) async {
final result = await _channel.invokeMethod('customSearchWithExtension', {
'extension_id': extensionId,
'query': query,
'options': options != null ? jsonEncode(options) : '',
});
return _decodeMapListResult(result, 'customSearchWithExtension');
final optionsJson = options != null ? jsonEncode(options) : '';
final scopeKey = 'customSearch:${extensionId.trim()}';
final cacheKey = [
scopeKey,
query,
jsonEncode(_canonicalizeJsonLike(options ?? const <String, dynamic>{})),
].join('\n');
final inFlight = _customSearchInFlight[cacheKey];
if (inFlight != null) return _copyMapList(await inFlight.future);
if (cancelPrevious) {
_cancelCustomSearchInFlightForScope(scopeKey, exceptKey: cacheKey);
}
final requestId = _nextExtensionRequestId('customSearch', extensionId);
final future = (() async {
final result = await _channel.invokeMethod('customSearchWithExtension', {
'extension_id': extensionId,
'query': query,
'options': optionsJson,
'request_id': requestId,
});
return _decodeMapListResult(result, 'customSearchWithExtension');
})();
final entry = _BridgeInFlight<List<Map<String, dynamic>>>(
requestId: requestId,
scopeKey: scopeKey,
future: future,
);
_customSearchInFlight[cacheKey] = entry;
try {
return _copyMapList(await future);
} finally {
if (identical(_customSearchInFlight[cacheKey], entry)) {
_customSearchInFlight.remove(cacheKey);
}
}
}
static Future<List<Map<String, dynamic>>> getSearchProviders() async {
@@ -927,16 +1380,44 @@ class PlatformBridge {
}
static Future<Map<String, dynamic>?> getExtensionHomeFeed(
String extensionId,
) async {
String extensionId, {
bool cancelPrevious = false,
}) async {
final cacheKey = 'homeFeed:${extensionId.trim()}';
final inFlight = _homeFeedInFlight[cacheKey];
if (inFlight != null) {
if (!cancelPrevious) {
return _copyNullableStringMap(await inFlight.future);
}
_cancelExtensionRequestUnawaited(inFlight.requestId);
_homeFeedInFlight.remove(cacheKey);
}
final requestId = _nextExtensionRequestId('homeFeed', extensionId);
final future = (() async {
try {
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
'extension_id': extensionId,
'request_id': requestId,
});
return _decodeNullableMapResult(result, 'getExtensionHomeFeed');
} catch (e) {
_log.e('getExtensionHomeFeed failed: $e');
return null;
}
})();
final entry = _BridgeInFlight<Map<String, dynamic>?>(
requestId: requestId,
scopeKey: cacheKey,
future: future,
);
_homeFeedInFlight[cacheKey] = entry;
try {
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
'extension_id': extensionId,
});
return _decodeNullableMapResult(result, 'getExtensionHomeFeed');
} catch (e) {
_log.e('getExtensionHomeFeed failed: $e');
return null;
return _copyNullableStringMap(await future);
} finally {
if (identical(_homeFeedInFlight[cacheKey], entry)) {
_homeFeedInFlight.remove(cacheKey);
}
}
}
@@ -969,7 +1450,7 @@ class PlatformBridge {
final result = await _channel.invokeMethod('scanLibraryFolder', {
'folder_path': folderPath,
});
return _decodeMapListResult(result, 'scanLibraryFolder');
return _decodeMapListResultAsync(result, 'scanLibraryFolder');
}
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
@@ -983,7 +1464,10 @@ class PlatformBridge {
'folder_path': folderPath,
'existing_files': jsonEncode(existingFiles),
});
return _decodeRequiredMapResult(result, 'scanLibraryFolderIncremental');
return _decodeRequiredMapResultAsync(
result,
'scanLibraryFolderIncremental',
);
}
static Future<Map<String, dynamic>> scanLibraryFolderIncrementalFromSnapshot(
@@ -994,7 +1478,7 @@ class PlatformBridge {
'scanLibraryFolderIncrementalFromSnapshot',
{'folder_path': folderPath, 'snapshot_path': snapshotPath},
);
return _decodeRequiredMapResult(
return _decodeRequiredMapResultAsync(
result,
'scanLibraryFolderIncrementalFromSnapshot',
);
@@ -1005,7 +1489,7 @@ class PlatformBridge {
final result = await _channel.invokeMethod('scanSafTree', {
'tree_uri': treeUri,
});
return _decodeMapListResult(result, 'scanSafTree');
return _decodeMapListResultAsync(result, 'scanSafTree');
}
static Future<Map<String, dynamic>> scanSafTreeIncremental(
@@ -1019,7 +1503,7 @@ class PlatformBridge {
'tree_uri': treeUri,
'existing_files': jsonEncode(existingFiles),
});
return _decodeRequiredMapResult(result, 'scanSafTreeIncremental');
return _decodeRequiredMapResultAsync(result, 'scanSafTreeIncremental');
}
static Future<Map<String, dynamic>> scanSafTreeIncrementalFromSnapshot(
@@ -1030,7 +1514,7 @@ class PlatformBridge {
'scanSafTreeIncrementalFromSnapshot',
{'tree_uri': treeUri, 'snapshot_path': snapshotPath},
);
return _decodeRequiredMapResult(
return _decodeRequiredMapResultAsync(
result,
'scanSafTreeIncrementalFromSnapshot',
);
@@ -1067,6 +1551,22 @@ class PlatformBridge {
return result;
}
static Future<Object?> _decodeJsonResultAsync(dynamic result) async {
if (result is Map && result[_jsonResultFileKey] is String) {
final file = File(result[_jsonResultFileKey] as String);
try {
final contents = await file.readAsString();
if (contents.isEmpty) return null;
return jsonDecode(contents);
} finally {
try {
await file.delete();
} catch (_) {}
}
}
return _decodeJsonResult(result);
}
static Map<String, dynamic> _decodeRequiredMapResult(
dynamic result,
String method,
@@ -1094,6 +1594,19 @@ class PlatformBridge {
);
}
static Future<Map<String, dynamic>> _decodeRequiredMapResultAsync(
dynamic result,
String method,
) async {
final decoded = await _decodeJsonResultAsync(result);
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
throw FormatException(
'Expected map result from $method, got ${decoded.runtimeType}',
);
}
static List<dynamic> _decodeRequiredListResult(
dynamic result,
String method,
@@ -1105,6 +1618,17 @@ class PlatformBridge {
);
}
static Future<List<dynamic>> _decodeRequiredListResultAsync(
dynamic result,
String method,
) async {
final decoded = await _decodeJsonResultAsync(result);
if (decoded is List) return decoded;
throw FormatException(
'Expected list result from $method, got ${decoded.runtimeType}',
);
}
static List<Map<String, dynamic>> _decodeMapListResult(
dynamic result,
String method,
@@ -1117,6 +1641,19 @@ class PlatformBridge {
}).toList();
}
static Future<List<Map<String, dynamic>>> _decodeMapListResultAsync(
dynamic result,
String method,
) async {
final decoded = await _decodeRequiredListResultAsync(result, method);
return decoded.map((entry) {
if (entry is Map) return entry.cast<String, dynamic>();
throw FormatException(
'Expected map entry from $method, got ${entry.runtimeType}',
);
}).toList();
}
static List<String> _decodeStringListResult(dynamic result, String method) {
return _decodeRequiredListResult(result, method).map((entry) {
if (entry is String) return entry;
+44 -10
View File
@@ -3,10 +3,14 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
class CachedCoverImage extends StatelessWidget {
static const int _defaultMinCacheExtent = 64;
static const int _defaultMaxCacheExtent = 512;
final String imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final Alignment alignment;
final int? memCacheWidth;
final int? memCacheHeight;
final Widget Function(BuildContext, String, Object)? errorWidget;
@@ -19,6 +23,7 @@ class CachedCoverImage extends StatelessWidget {
this.width,
this.height,
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.memCacheWidth,
this.memCacheHeight,
this.errorWidget,
@@ -28,36 +33,65 @@ class CachedCoverImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final autoMemCacheWidth =
memCacheWidth ?? _cacheExtentForLogicalSize(context, width);
final autoMemCacheHeight =
memCacheHeight ?? _cacheExtentForLogicalSize(context, height);
final image = CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
memCacheWidth: memCacheWidth,
memCacheHeight: memCacheHeight,
cacheManager: CoverCacheManager.isInitialized
? CoverCacheManager.instance
alignment: alignment,
memCacheWidth: autoMemCacheWidth,
memCacheHeight: autoMemCacheHeight,
maxWidthDiskCache: autoMemCacheWidth,
maxHeightDiskCache: autoMemCacheHeight,
cacheManager: CoverCacheManager.isInitialized
? CoverCacheManager.instance
: null,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
useOldImageOnUrlChange: true,
filterQuality: FilterQuality.low,
errorWidget: errorWidget,
placeholder: placeholder,
);
if (borderRadius != null) {
return ClipRRect(
borderRadius: borderRadius!,
child: image,
);
return ClipRRect(borderRadius: borderRadius!, child: image);
}
return image;
}
static int? _cacheExtentForLogicalSize(BuildContext context, double? size) {
if (size == null || !size.isFinite || size <= 0) return null;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
return (size * dpr)
.round()
.clamp(_defaultMinCacheExtent, _defaultMaxCacheExtent)
.toInt();
}
}
CachedNetworkImageProvider cachedCoverImageProvider(String url) {
return CachedNetworkImageProvider(
url,
cacheManager: CoverCacheManager.isInitialized
? CoverCacheManager.instance
cacheManager: CoverCacheManager.isInitialized
? CoverCacheManager.instance
: null,
);
}
int coverImageCacheExtent(
BuildContext context,
double logicalSize, {
int min = 64,
int max = 512,
}) {
final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble();
return (logicalSize * dpr).round().clamp(min, max).toInt();
}