feat: unify search results display and add album search to Deezer

- Add SearchAlbumResult struct to Go backend
- Add album search to Deezer SearchAll() function (returns albums alongside tracks/artists)
- Change artist display from horizontal scroll to vertical list style (consistent with extension search)
- Add SearchAlbum class and searchAlbums field to TrackState
- Add _SearchArtistItemWidget and _SearchAlbumItemWidget for vertical list display
- Add _navigateToSearchAlbum method for navigating to album details
- Remove old horizontal artist scroll (_buildArtistSearchResults, _buildArtistCard)

Now default search (Deezer/Spotify) shows Artists, Albums, and Songs in the same vertical list style as extension search results.
This commit is contained in:
zarzet
2026-01-31 12:00:29 +07:00
parent 5f999035c3
commit 74bac570c7
4 changed files with 425 additions and 85 deletions
+65 -2
View File
@@ -186,7 +186,8 @@ type deezerPlaylistFull struct {
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
albumLimit := 5 // Same as artistLimit for consistency
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d", query, trackLimit, artistLimit, albumLimit)
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -199,6 +200,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
}
// Search tracks - NO ISRC fetch for performance
@@ -229,6 +231,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
result.Tracks = append(result.Tracks, c.convertTrack(track))
}
// Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
@@ -259,7 +262,67 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] Artist search failed: %v\n", err)
}
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Search albums
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
var albumResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
NbTracks int `json:"nb_tracks"`
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
Artist deezerArtist `json:"artist"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, albumURL, &albumResp); err == nil {
if albumResp.Error != nil {
GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message)
} else {
GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data))
for _, album := range albumResp.Data {
coverURL := album.CoverXL
if coverURL == "" {
coverURL = album.CoverBig
}
if coverURL == "" {
coverURL = album.CoverMedium
}
if coverURL == "" {
coverURL = album.Cover
}
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
result.Albums = append(result.Albums, SearchAlbumResult{
ID: fmt.Sprintf("deezer:%d", album.ID),
Name: album.Title,
Artists: album.Artist.Name,
Images: coverURL,
ReleaseDate: album.ReleaseDate,
TotalTracks: album.NbTracks,
AlbumType: albumType,
})
}
}
} else {
GoLog("[Deezer] Album search failed: %v\n", err)
}
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
+11
View File
@@ -238,9 +238,20 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"`
}
type SearchAlbumResult struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type"`
}
type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
Albums []SearchAlbumResult `json:"albums"`
}
type spotifyURI struct {
+56 -4
View File
@@ -22,6 +22,7 @@ class TrackState {
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
final List<SearchAlbum>? searchAlbums; // For search results (albums)
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results
@@ -42,13 +43,14 @@ class TrackState {
this.artistAlbums,
this.artistTopTracks,
this.searchArtists,
this.searchAlbums,
this.hasSearchText = false,
this.isShowingRecentAccess = false,
this.searchExtensionId,
this.selectedSearchFilter,
});
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty);
TrackState copyWith({
List<Track>? tracks,
@@ -65,6 +67,7 @@ class TrackState {
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
List<SearchArtist>? searchArtists,
List<SearchAlbum>? searchAlbums,
bool? hasSearchText,
bool? isShowingRecentAccess,
String? searchExtensionId,
@@ -86,6 +89,7 @@ class TrackState {
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
searchArtists: searchArtists ?? this.searchArtists,
searchAlbums: searchAlbums ?? this.searchAlbums,
hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId,
@@ -132,6 +136,26 @@ class SearchArtist {
});
}
class SearchAlbum {
final String id;
final String name;
final String artists;
final String? imageUrl;
final String? releaseDate;
final int totalTracks;
final String albumType;
const SearchAlbum({
required this.id,
required this.name,
required this.artists,
this.imageUrl,
this.releaseDate,
required this.totalTracks,
required this.albumType,
});
}
class TrackNotifier extends Notifier<TrackState> {
int _currentRequestId = 0;
@@ -321,7 +345,7 @@ class TrackNotifier extends Notifier<TrackState> {
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
} else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
@@ -335,8 +359,9 @@ class TrackNotifier extends Notifier<TrackState> {
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final albumList = results['albums'] as List<dynamic>? ?? [];
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
final tracks = <Track>[];
@@ -378,11 +403,26 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully');
final albums = <SearchAlbum>[];
for (int i = 0; i < albumList.length; i++) {
final a = albumList[i];
try {
if (a is Map<String, dynamic>) {
albums.add(_parseSearchAlbum(a));
} else {
_log.w('Album[$i] is not a Map: ${a.runtimeType}');
}
} catch (e) {
_log.e('Failed to parse album[$i]: $e', e);
}
}
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums parsed successfully');
state = TrackState(
tracks: tracks,
searchArtists: artists,
searchAlbums: albums,
isLoading: false,
hasSearchText: state.hasSearchText,
);
@@ -590,6 +630,18 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
SearchAlbum _parseSearchAlbum(Map<String, dynamic> data) {
return SearchAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
artists: data['artists'] as String? ?? '',
imageUrl: data['images'] as String?,
releaseDate: data['release_date'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0,
albumType: data['album_type'] as String? ?? 'album',
);
}
void _preWarmCacheForTracks(List<Track> tracks) {
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
+293 -79
View File
@@ -466,6 +466,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
final searchAlbums = ref.watch(trackProvider.select((s) => s.searchAlbums));
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final error = ref.watch(trackProvider.select((s) => s.error));
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
@@ -481,7 +482,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
));
final colorScheme = Theme.of(context).colorScheme;
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || (searchAlbums != null && searchAlbums.isNotEmpty);
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
final mediaQuery = MediaQuery.of(context);
@@ -681,6 +682,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
..._buildSearchResults(
tracks: tracks,
searchArtists: searchArtists,
searchAlbums: searchAlbums,
isLoading: isLoading,
error: error,
colorScheme: colorScheme,
@@ -1559,6 +1561,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
List<Widget> _buildSearchResults({
required List<Track> tracks,
required List<SearchArtist>? searchArtists,
required List<SearchAlbum>? searchAlbums,
required bool isLoading,
required String? error,
required ColorScheme colorScheme,
@@ -1601,9 +1604,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (isLoading)
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
// Artists from default search (Deezer/Spotify) - now in vertical list style
if (searchArtists != null && searchArtists.isNotEmpty)
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(context.l10n.searchArtists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
if (searchArtists != null && searchArtists.isNotEmpty)
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < searchArtists.length; i++)
_SearchArtistItemWidget(
key: ValueKey('search-artist-${searchArtists[i].id}'),
artist: searchArtists[i],
showDivider: i < searchArtists.length - 1,
onTap: () => _navigateToArtist(searchArtists[i].id, searchArtists[i].name, searchArtists[i].imageUrl),
),
],
),
),
),
),
// Artists from extension search
if (artistItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -1638,6 +1674,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
),
// Albums from default search (Deezer/Spotify)
if (searchAlbums != null && searchAlbums.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(context.l10n.searchAlbums, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
if (searchAlbums != null && searchAlbums.isNotEmpty)
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < searchAlbums.length; i++)
_SearchAlbumItemWidget(
key: ValueKey('search-album-${searchAlbums[i].id}'),
album: searchAlbums[i],
showDivider: i < searchAlbums.length - 1,
onTap: () => _navigateToSearchAlbum(searchAlbums[i]),
),
],
),
),
),
),
// Albums from extension search
if (albumItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -1746,83 +1818,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
];
}
Widget _buildArtistSearchResults(List<SearchArtist> artists, ColorScheme colorScheme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(context.l10n.searchArtists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index];
return KeyedSubtree(
key: ValueKey(artist.id),
child: _buildArtistCard(artist, colorScheme),
);
},
),
),
],
);
}
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
final hasValidImage = artist.imageUrl != null &&
artist.imageUrl!.isNotEmpty &&
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
return GestureDetector(
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
child: Container(
width: 110,
margin: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.surfaceContainerHighest,
),
child: ClipOval(
child: hasValidImage
? CachedNetworkImage(
imageUrl: artist.imageUrl!,
fit: BoxFit.cover,
memCacheWidth: 200,
memCacheHeight: 200,
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Icon(
Icons.person,
color: colorScheme.onSurfaceVariant,
size: 44,
),
)
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
),
),
const SizedBox(height: 8),
Text(
artist.name,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
);
}
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
ref.read(settingsProvider.notifier).setHasSearchedBefore();
@@ -1835,6 +1830,33 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
));
}
void _navigateToSearchAlbum(SearchAlbum album) {
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Extract the numeric ID from "deezer:123" format
String albumId = album.id;
if (albumId.startsWith('deezer:')) {
albumId = albumId.substring(7);
}
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: album.id,
name: album.name,
artistName: album.artists,
imageUrl: album.imageUrl,
providerId: 'deezer',
);
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: albumId,
albumName: album.name,
coverUrl: album.imageUrl,
tracks: const [], // Will be fetched by AlbumScreen
),
));
}
void _navigateToExtensionAlbum(Track albumItem) async {
final extensionId = albumItem.source;
if (extensionId == null || extensionId.isEmpty) {
@@ -2609,6 +2631,198 @@ class _CollectionItemWidget extends StatelessWidget {
}
}
/// Widget for displaying artist items from default search (Deezer/Spotify)
class _SearchArtistItemWidget extends StatelessWidget {
final SearchArtist artist;
final bool showDivider;
final VoidCallback onTap;
const _SearchArtistItemWidget({
super.key,
required this.artist,
required this.showDivider,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasValidImage = artist.imageUrl != null &&
artist.imageUrl!.isNotEmpty &&
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(28),
child: hasValidImage
? CachedNetworkImage(
imageUrl: artist.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
artist.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'Artist',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
size: 24,
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 80,
endIndent: 12,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
/// Widget for displaying album items from default search (Deezer/Spotify)
class _SearchAlbumItemWidget extends StatelessWidget {
final SearchAlbum album;
final bool showDivider;
final VoidCallback onTap;
const _SearchAlbumItemWidget({
super.key,
required this.album,
required this.showDivider,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasValidImage = album.imageUrl != null &&
album.imageUrl!.isNotEmpty &&
Uri.tryParse(album.imageUrl!)?.hasAuthority == true;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: hasValidImage
? CachedNetworkImage(
imageUrl: album.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
album.artists.isNotEmpty ? album.artists : 'Album',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
size: 24,
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 80,
endIndent: 12,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String extensionId;
final String albumId;