mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-13 22:50:20 +02:00
perf: reduce bridge and UI churn
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -778,6 +778,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
extensionId,
|
||||
query,
|
||||
options: options,
|
||||
cancelPrevious: true,
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user