feat: artist navigation from album, UI improvements, concise changelog

- Add tappable artist name in album screen to navigate to artist page
- Show track number instead of cover image in album track list
- Add release date badge next to track count on album screen
- Modernize Download All buttons with rounded corners (borderRadius: 24)
- Add downloaded indicator for recent items (primary colored subtitle)
- Condense v3.2.0 changelog and add note about concise format
- Fix withOpacity deprecation and unnecessary null assertion in home_tab
- Go backend: add artist_id support for Spotify, Deezer, and extensions
This commit is contained in:
zarzet
2026-01-21 12:07:50 +07:00
parent 7844bd2f42
commit f2f8ca4528
10 changed files with 244 additions and 1816 deletions
+54 -1768
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -325,6 +325,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
+1
View File
@@ -1720,6 +1720,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"artist_id": album.ArtistID,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
+1
View File
@@ -58,6 +58,7 @@ type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
+9
View File
@@ -170,6 +170,7 @@ type AlbumInfoMetadata struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
@@ -512,11 +513,19 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string
if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID
}
info := AlbumInfoMetadata{
TotalTracks: data.TotalTracks,
Name: data.Name,
ReleaseDate: data.ReleaseDate,
Artists: joinArtists(data.Artists),
ArtistId: firstArtistId,
Images: albumImage,
}
+10 -10
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
"historyTracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}",
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbumes}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
"albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
"artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}",
"artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1108,7 +1108,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -1169,7 +1169,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}",
"snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1916,7 +1916,7 @@
}
}
},
"tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
"tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -2520,7 +2520,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -2559,7 +2559,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
+6 -6
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
"historyTracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}",
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbuns}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
"albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
"artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}",
"artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
"selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}",
"selectionDeleteTracks": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1916,7 +1916,7 @@
}
}
},
"tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
"tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
+137 -25
View File
@@ -12,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -43,6 +45,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null
final String? extensionId; // If from extension
final String? artistId; // Artist ID for navigation
final String? artistName; // Artist name for navigation
const AlbumScreen({
super.key,
@@ -50,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
required this.albumName,
this.coverUrl,
this.tracks,
this.extensionId,
this.artistId,
this.artistName,
});
@override
@@ -62,6 +70,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
String? _artistId;
final ScrollController _scrollController = ScrollController();
@override
@@ -78,10 +87,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
);
});
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
_artistId = widget.artistId; // Use provided artist ID if available
if (_tracks == null) {
_fetchTracks();
}
@@ -103,7 +114,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
Future<void> _extractDominantColor() async {
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
@@ -111,7 +122,25 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
Future<void> _fetchTracks() async {
String _formatReleaseDate(String date) {
// Handle formats: "2024-01-15", "2024-01", "2024"
if (date.length >= 10) {
// Full date: 2024-01-15
final parts = date.substring(0, 10).split('-');
if (parts.length == 3) {
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
}
} else if (date.length >= 7) {
// Month: 2024-01
final parts = date.split('-');
if (parts.length >= 2) {
return '${parts[1]}/${parts[0]}'; // MM/YYYY
}
}
return date; // Year only or unknown format
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
Map<String, dynamic> metadata;
@@ -127,11 +156,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Extract artist ID from album_info if available
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
@@ -300,9 +334,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter(
child: Padding(
@@ -322,32 +357,59 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
GestureDetector(
onTap: () => _navigateToArtist(context, artistName),
child: Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
],
@@ -426,10 +488,51 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
void _navigateToArtist(BuildContext context, String artistName) {
// Use stored artist ID if available, otherwise use a placeholder
final artistId = _artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
// Don't navigate if artist ID is unknown
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
);
return;
}
// If from extension, use ExtensionArtistScreen
if (widget.extensionId != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: widget.extensionId!,
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
@@ -524,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
leading: SizedBox(
width: 32,
child: Center(
child: Text(
'${track.trackNumber ?? 0}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
+19 -4
View File
@@ -726,7 +726,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
greeting!,
greeting,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -1230,6 +1230,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) {
IconData typeIcon;
String typeLabel;
final isDownloaded = item.providerId == 'download';
switch (item.type) {
case RecentAccessType.artist:
typeIcon = Icons.person;
@@ -1293,11 +1295,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
const SizedBox(height: 4),
Text(
item.subtitle != null ? '$typeLabel${item.subtitle}' : typeLabel,
isDownloaded
? (item.subtitle != null ? '${context.l10n.recentTypeSong}${item.subtitle}' : context.l10n.recentTypeSong)
: (item.subtitle != null ? '$typeLabel${item.subtitle}' : typeLabel),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
color: isDownloaded ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
),
],
@@ -2441,6 +2445,8 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
List<Track>? _tracks;
bool _isLoading = true;
String? _error;
String? _artistId;
String? _artistName;
@override
void initState() {
@@ -2480,8 +2486,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Extract artist info from album response
final artistId = result['artist_id'] as String?;
final artistName = result['artists'] as String?;
setState(() {
_tracks = tracks;
_artistId = artistId;
_artistName = artistName;
_isLoading = false;
});
} catch (e) {
@@ -2550,6 +2562,9 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
albumName: widget.albumName,
coverUrl: widget.coverUrl,
tracks: _tracks,
extensionId: widget.extensionId,
artistId: _artistId,
artistName: _artistName,
);
}
}
@@ -2933,7 +2948,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
shape: BoxShape.circle,
color: isActive
? widget.colorScheme.primary
: widget.colorScheme.onSurfaceVariant.withOpacity(0.3),
: widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
),
);
}),
+6 -3
View File
@@ -215,12 +215,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
],
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
),