feat: add home tab enhancements, download queue improvements, and platform bridge updates

This commit is contained in:
zarzet
2026-01-15 04:31:33 +07:00
parent 4091a9c499
commit 82440affac
22 changed files with 1373 additions and 70 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.0.0';
static const String buildNumber = '57';
static const String version = '3.0.1';
static const String buildNumber = '58';
static const String fullVersion = '$version+$buildNumber';
+1 -1
View File
@@ -28,7 +28,7 @@ class AppSettings {
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final String albumFolderStructure; // artist_album or album_only
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation
const AppSettings({
+2 -1
View File
@@ -32,7 +32,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
);
+14
View File
@@ -20,6 +20,7 @@ class Track {
final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources)
final String? albumType; // album, single, ep, compilation (from metadata API)
final String? itemType; // track, album, playlist - for extension search results
const Track({
required this.id,
@@ -37,10 +38,23 @@ class Track {
this.availability,
this.source,
this.albumType,
this.itemType,
});
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
+2
View File
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
itemType: json['itemType'] as String?,
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'itemType': instance.itemType,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
+28 -7
View File
@@ -688,15 +688,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else {
// Albums folder structure based on setting
final albumName = _sanitizeFolderName(track.albumName);
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
final year = _extractYear(track.releaseDate);
String albumPath;
if (albumFolderStructure == 'album_only') {
// Albums/Album structure (no artist folder)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
} else {
// Albums/Artist/Album structure (default)
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
switch (albumFolderStructure) {
case 'album_only':
// Albums/Album structure (no artist folder)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
break;
case 'artist_year_album':
// Albums/Artist/[Year] Album structure
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
break;
case 'year_album':
// Albums/[Year] Album structure (no artist folder)
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
break;
default:
// Albums/Artist/Album structure (default: artist_album)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
}
final dir = Directory(albumPath);
@@ -751,6 +764,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.trim();
}
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
// Handle both "2005-06-13" and "2005" formats
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
return match?.group(1);
}
void updateSettings(AppSettings settings) {
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty
+24 -1
View File
@@ -82,6 +82,7 @@ class ArtistAlbum {
final String? coverUrl;
final String albumType; // album, single, compilation
final String artists;
final String? providerId; // Extension ID if from extension
const ArtistAlbum({
required this.id,
@@ -91,6 +92,7 @@ class ArtistAlbum {
this.coverUrl,
required this.albumType,
required this.artists,
this.providerId,
});
}
@@ -479,6 +481,23 @@ class TrackNotifier extends Notifier<TrackState> {
void setSearchText(bool hasText) {
state = state.copyWith(hasSearchText: hasText);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
String? albumName,
String? playlistName,
String? coverUrl,
}) {
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumName,
playlistName: playlistName,
coverUrl: coverUrl,
hasSearchText: state.hasSearchText,
);
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
@@ -506,13 +525,16 @@ class TrackNotifier extends Notifier<TrackState> {
durationMs = durationValue.toInt();
}
// Get item_type - can be 'track', 'album', or 'playlist'
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: data['images']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -520,6 +542,7 @@ class TrackNotifier extends Notifier<TrackState> {
releaseDate: data['release_date']?.toString(),
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
}
+24 -8
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
/// Simple in-memory cache for artist discography
class _ArtistCache {
@@ -346,14 +347,29 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void _navigateToAlbum(ArtistAlbum album) {
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
ref.read(settingsProvider.notifier).setHasSearchedBefore();
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
// tracks: null - will be fetched in AlbumScreen
),
));
// Check if this album is from an extension (has providerId)
if (album.providerId != null && album.providerId!.isNotEmpty) {
// Use ExtensionAlbumScreen for extension albums
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: album.providerId!,
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
),
));
} else {
// Use regular AlbumScreen for Spotify/Deezer albums
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
// tracks: null - will be fetched in AlbumScreen
),
));
}
}
/// Build error widget with special handling for rate limit (429)
+93 -9
View File
@@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
final isCollection = track.isCollection;
// Determine subtitle text based on item type
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
@@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
track.artistName,
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => _downloadTrack(index),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
);
}
Future<void> _openCollection(Track track) async {
// Get the extension ID from the track source
final extensionId = track.source;
if (extensionId == null) return;
// Fetch album/playlist tracks using the extension
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
+686 -13
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -636,6 +637,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
}
// Separate tracks from albums/playlists/artists
final realTracks = tracks.where((t) => !t.isCollection).toList();
final albumItems = tracks.where((t) => t.isAlbumItem).toList();
final playlistItems = tracks.where((t) => t.isPlaylistItem).toList();
final artistItems = tracks.where((t) => t.isArtistItem).toList();
return [
// Error message - with special handling for rate limit (429)
if (error != null)
@@ -648,19 +655,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (isLoading)
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
// Artist search results (horizontal scroll)
// Artist search results (horizontal scroll) - from built-in providers
if (searchArtists != null && searchArtists.isNotEmpty)
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
// Songs section header
if (tracks.isNotEmpty)
// Artists section - from extension search
if (artistItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
// Track list in grouped card
if (tracks.isNotEmpty)
if (artistItems.isNotEmpty)
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
@@ -676,13 +681,120 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < tracks.length; i++)
for (int i = 0; i < artistItems.length; i++)
_CollectionItemWidget(
key: ValueKey('artist-${artistItems[i].id}'),
item: artistItems[i],
showDivider: i < artistItems.length - 1,
onTap: () => _navigateToExtensionArtist(artistItems[i]),
),
],
),
),
),
),
// Albums section
if (albumItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Albums', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
if (albumItems.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 < albumItems.length; i++)
_CollectionItemWidget(
key: ValueKey('album-${albumItems[i].id}'),
item: albumItems[i],
showDivider: i < albumItems.length - 1,
onTap: () => _navigateToExtensionAlbum(albumItems[i]),
),
],
),
),
),
),
// Playlists section
if (playlistItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Playlists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
if (playlistItems.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 < playlistItems.length; i++)
_CollectionItemWidget(
key: ValueKey('playlist-${playlistItems[i].id}'),
item: playlistItems[i],
showDivider: i < playlistItems.length - 1,
onTap: () => _navigateToExtensionPlaylist(playlistItems[i]),
),
],
),
),
),
),
// Songs section header
if (realTracks.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
// Track list in grouped card
if (realTracks.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 < realTracks.length; i++)
_TrackItemWithStatus(
key: ValueKey(tracks[i].id),
track: tracks[i],
index: i,
showDivider: i < tracks.length - 1,
onDownload: () => _downloadTrack(i),
key: ValueKey(realTracks[i].id),
track: realTracks[i],
index: tracks.indexOf(realTracks[i]), // Use original index for download
showDivider: i < realTracks.length - 1,
onDownload: () => _downloadTrack(tracks.indexOf(realTracks[i])),
),
],
),
@@ -785,6 +897,72 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
));
}
void _navigateToExtensionAlbum(Track albumItem) async {
final extensionId = albumItem.source;
if (extensionId == null || extensionId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot load album: missing extension source')),
);
return;
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Navigate to AlbumScreen - it will fetch tracks via extension
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: extensionId,
albumId: albumItem.id,
albumName: albumItem.name,
coverUrl: albumItem.coverUrl,
),
));
}
void _navigateToExtensionPlaylist(Track playlistItem) async {
final extensionId = playlistItem.source;
if (extensionId == null || extensionId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot load playlist: missing extension source')),
);
return;
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionPlaylistScreen(
extensionId: extensionId,
playlistId: playlistItem.id,
playlistName: playlistItem.name,
coverUrl: playlistItem.coverUrl,
),
));
}
void _navigateToExtensionArtist(Track artistItem) {
final extensionId = artistItem.source;
if (extensionId == null || extensionId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot load artist: missing extension source')),
);
return;
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Navigate to ExtensionArtistScreen - it will fetch albums via extension
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: extensionId,
artistId: artistItem.id,
artistName: artistItem.name,
coverUrl: artistItem.coverUrl,
),
));
}
/// Get search hint based on selected provider
String _getSearchHint() {
final settings = ref.read(settingsProvider);
@@ -1109,3 +1287,498 @@ class _TrackItemWithStatus extends ConsumerWidget {
}
}
}
/// Widget for displaying album/playlist items in search results
class _CollectionItemWidget extends StatelessWidget {
final Track item;
final bool showDivider;
final VoidCallback onTap;
const _CollectionItemWidget({
super.key,
required this.item,
required this.showDivider,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isPlaylist = item.isPlaylistItem;
final isArtist = item.isArtistItem;
// Determine icon for placeholder
IconData placeholderIcon = Icons.album;
if (isPlaylist) placeholderIcon = Icons.playlist_play;
if (isArtist) placeholderIcon = Icons.person;
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: [
// Cover art (circular for artists)
ClipRRect(
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
placeholderIcon,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
item.artistName.isNotEmpty ? item.artistName : (isPlaylist ? 'Playlist' : 'Album'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Arrow indicator
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),
),
],
);
}
}
/// Screen for viewing extension album with track fetching
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String extensionId;
final String albumId;
final String albumName;
final String? coverUrl;
const ExtensionAlbumScreen({
super.key,
required this.extensionId,
required this.albumId,
required this.albumName,
this.coverUrl,
});
@override
ConsumerState<ExtensionAlbumScreen> createState() => _ExtensionAlbumScreenState();
}
class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
List<Track>? _tracks;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchTracks();
}
Future<void> _fetchTracks() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await PlatformBridge.getAlbumWithExtension(
widget.extensionId,
widget.albumId,
);
if (result == null) {
setState(() {
_error = 'Failed to load album';
_isLoading = false;
});
return;
}
// Parse tracks from result
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = 'No tracks found';
_isLoading = false;
});
return;
}
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
setState(() {
_tracks = tracks;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Error: $e';
_isLoading = false;
});
}
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? widget.albumName).toString(),
coverUrl: _resolveCoverUrl(data['cover_url']?.toString(), widget.coverUrl),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
source: widget.extensionId,
);
}
String? _resolveCoverUrl(String? trackCover, String? albumCover) {
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
return albumCover;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: CircularProgressIndicator()),
);
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')),
],
),
),
);
}
// Navigate to AlbumScreen with fetched tracks
return AlbumScreen(
albumId: widget.albumId,
albumName: widget.albumName,
coverUrl: widget.coverUrl,
tracks: _tracks,
);
}
}
/// Screen for viewing extension playlist with track fetching
class ExtensionPlaylistScreen extends ConsumerStatefulWidget {
final String extensionId;
final String playlistId;
final String playlistName;
final String? coverUrl;
const ExtensionPlaylistScreen({
super.key,
required this.extensionId,
required this.playlistId,
required this.playlistName,
this.coverUrl,
});
@override
ConsumerState<ExtensionPlaylistScreen> createState() => _ExtensionPlaylistScreenState();
}
class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScreen> {
List<Track>? _tracks;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchTracks();
}
Future<void> _fetchTracks() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await PlatformBridge.getPlaylistWithExtension(
widget.extensionId,
widget.playlistId,
);
if (result == null) {
setState(() {
_error = 'Failed to load playlist';
_isLoading = false;
});
return;
}
// Parse tracks from result
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = 'No tracks found';
_isLoading = false;
});
return;
}
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
setState(() {
_tracks = tracks;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Error: $e';
_isLoading = false;
});
}
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: _resolveCoverUrl(data['cover_url']?.toString(), widget.coverUrl),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
source: widget.extensionId,
);
}
String? _resolveCoverUrl(String? trackCover, String? playlistCover) {
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
return playlistCover;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.playlistName)),
body: const Center(child: CircularProgressIndicator()),
);
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(widget.playlistName)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')),
],
),
),
);
}
// Navigate to PlaylistScreen with fetched tracks
return PlaylistScreen(
playlistName: widget.playlistName,
coverUrl: widget.coverUrl,
tracks: _tracks!,
);
}
}
/// Screen for viewing extension artist with album fetching
class ExtensionArtistScreen extends ConsumerStatefulWidget {
final String extensionId;
final String artistId;
final String artistName;
final String? coverUrl;
const ExtensionArtistScreen({
super.key,
required this.extensionId,
required this.artistId,
required this.artistName,
this.coverUrl,
});
@override
ConsumerState<ExtensionArtistScreen> createState() => _ExtensionArtistScreenState();
}
class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
List<ArtistAlbum>? _albums;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchArtist();
}
Future<void> _fetchArtist() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await PlatformBridge.getArtistWithExtension(
widget.extensionId,
widget.artistId,
);
if (result == null) {
setState(() {
_error = 'Failed to load artist';
_isLoading = false;
});
return;
}
// Parse albums from result
final albumList = result['albums'] as List<dynamic>?;
if (albumList == null) {
setState(() {
_albums = [];
_isLoading = false;
});
return;
}
final albums = albumList.map((a) => _parseAlbum(a as Map<String, dynamic>)).toList();
setState(() {
_albums = albums;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Error: $e';
_isLoading = false;
});
}
}
ArtistAlbum _parseAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artists: (data['artists'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['cover_url']?.toString(),
albumType: (data['album_type'] ?? 'album').toString(),
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.artistName)),
body: const Center(child: CircularProgressIndicator()),
);
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(widget.artistName)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _fetchArtist, child: const Text('Retry')),
],
),
),
);
}
// Navigate to ArtistScreen with fetched albums
return ArtistScreen(
artistId: widget.artistId,
artistName: widget.artistName,
coverUrl: widget.coverUrl,
albums: _albums,
);
}
}
@@ -200,9 +200,7 @@ class DownloadSettingsPage extends ConsumerWidget {
SettingsItem(
icon: Icons.folder_outlined,
title: 'Album Folder Structure',
subtitle: settings.albumFolderStructure == 'album_only'
? 'Albums/Album Name/'
: 'Albums/Artist/Album Name/',
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
onTap: () => _showAlbumFolderStructurePicker(
context,
ref,
@@ -234,6 +232,19 @@ class DownloadSettingsPage extends ConsumerWidget {
);
}
String _getAlbumFolderStructureLabel(String structure) {
switch (structure) {
case 'album_only':
return 'Albums/Album Name/';
case 'artist_year_album':
return 'Albums/Artist/[Year] Album/';
case 'year_album':
return 'Albums/[Year] Album/';
default:
return 'Albums/Artist/Album Name/';
}
}
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
showModalBottomSheet(
context: context,
@@ -251,6 +262,16 @@ class DownloadSettingsPage extends ConsumerWidget {
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.calendar_today_outlined),
title: const Text('Artist / [Year] Album'),
subtitle: const Text('Albums/Artist Name/[2005] Album Name/'),
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: const Text('Album Only'),
@@ -261,6 +282,16 @@ class DownloadSettingsPage extends ConsumerWidget {
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.event_outlined),
title: const Text('[Year] Album Only'),
subtitle: const Text('Albums/[2005] Album Name/'),
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
Navigator.pop(context);
},
),
],
),
),
@@ -907,16 +907,12 @@ class _SourceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback? onTap;
final String? badge;
final Color? badgeColor;
const _SourceChip({
required this.icon,
required this.label,
required this.isSelected,
this.onTap,
this.badge,
this.badgeColor,
});
@override
@@ -962,24 +958,6 @@ class _SourceChip extends StatelessWidget {
: colorScheme.onSurfaceVariant,
),
),
if (badge != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
badge!,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: badgeColor ?? colorScheme.tertiary,
),
),
),
],
],
),
),
+54
View File
@@ -787,6 +787,60 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Get album tracks using an extension
static Future<Map<String, dynamic>?> getAlbumWithExtension(
String extensionId,
String albumId,
) async {
try {
final result = await _channel.invokeMethod('getAlbumWithExtension', {
'extension_id': extensionId,
'album_id': albumId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getAlbumWithExtension failed: $e');
return null;
}
}
/// Get playlist tracks using an extension
static Future<Map<String, dynamic>?> getPlaylistWithExtension(
String extensionId,
String playlistId,
) async {
try {
final result = await _channel.invokeMethod('getPlaylistWithExtension', {
'extension_id': extensionId,
'playlist_id': playlistId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getPlaylistWithExtension failed: $e');
return null;
}
}
/// Get artist info and albums using an extension
static Future<Map<String, dynamic>?> getArtistWithExtension(
String extensionId,
String artistId,
) async {
try {
final result = await _channel.invokeMethod('getArtistWithExtension', {
'extension_id': extensionId,
'artist_id': artistId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getArtistWithExtension failed: $e');
return null;
}
}
// ==================== EXTENSION POST-PROCESSING ====================
/// Run post-processing hooks on a file