mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
refactor(ui): consolidate duplicated sheets, rows, and cover widgets
- single-track convert sheet now reuses BatchConvertSheet (confirmLabelBuilder + sourceIsLossless params) instead of a full inline copy - provider priority page migrated to PrioritySettingsScaffold; shared showDiscardChangesDialog and ReorderablePriorityItem replace per-page copies - home tab search rows unified into one _SearchResultRowItem; _parseTrack copies rebuilt on Track.fromBackendMap with only the load-bearing local overrides kept; loading/error scaffold extracted - PlayerArtwork widget shared by now-playing and mini player - LocalOrNetworkCoverImage dispatches file vs network cover in one place (playlist picker, queue nav, library folder)
This commit is contained in:
+236
-357
@@ -517,15 +517,23 @@ class _CollectionItemWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for displaying artist items from default search (Deezer/Spotify)
|
||||
class _SearchArtistItemWidget extends StatelessWidget {
|
||||
final SearchArtist artist;
|
||||
/// Generic row for artist/album/playlist results from default search
|
||||
/// (Deezer/Spotify); cover shape, fallback icon, and subtitle vary per type.
|
||||
class _SearchResultRowItem extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final double coverBorderRadius;
|
||||
final IconData fallbackIcon;
|
||||
final String title;
|
||||
final Widget subtitle;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SearchArtistItemWidget({
|
||||
super.key,
|
||||
required this.artist,
|
||||
const _SearchResultRowItem({
|
||||
required this.imageUrl,
|
||||
required this.coverBorderRadius,
|
||||
required this.fallbackIcon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
});
|
||||
@@ -534,9 +542,9 @@ class _SearchArtistItemWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasValidImage =
|
||||
artist.imageUrl != null &&
|
||||
artist.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||
imageUrl != null &&
|
||||
imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(imageUrl!)?.hasAuthority == true;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -550,10 +558,10 @@ class _SearchArtistItemWidget extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(coverBorderRadius),
|
||||
child: hasValidImage
|
||||
? CachedCoverImage(
|
||||
imageUrl: artist.imageUrl!,
|
||||
imageUrl: imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
@@ -563,7 +571,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
fallbackIcon,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -574,7 +582,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artist.name,
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -582,14 +590,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.l10n.recentTypeArtist,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -615,6 +616,41 @@ class _SearchArtistItemWidget 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;
|
||||
return _SearchResultRowItem(
|
||||
imageUrl: artist.imageUrl,
|
||||
coverBorderRadius: 28,
|
||||
fallbackIcon: Icons.person,
|
||||
title: artist.name,
|
||||
subtitle: Text(
|
||||
context.l10n.recentTypeArtist,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
showDivider: showDivider,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for displaying album items from default search (Deezer/Spotify)
|
||||
class _SearchAlbumItemWidget extends StatelessWidget {
|
||||
final SearchAlbum album;
|
||||
@@ -631,87 +667,24 @@ class _SearchAlbumItemWidget extends StatelessWidget {
|
||||
@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
|
||||
? CachedCoverImage(
|
||||
imageUrl: album.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: 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),
|
||||
ClickableArtistName(
|
||||
artistName: album.artists.isNotEmpty
|
||||
? album.artists
|
||||
: context.l10n.recentTypeAlbum,
|
||||
coverUrl: album.imageUrl,
|
||||
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),
|
||||
),
|
||||
],
|
||||
return _SearchResultRowItem(
|
||||
imageUrl: album.imageUrl,
|
||||
coverBorderRadius: 10,
|
||||
fallbackIcon: Icons.album,
|
||||
title: album.name,
|
||||
subtitle: ClickableArtistName(
|
||||
artistName: album.artists.isNotEmpty
|
||||
? album.artists
|
||||
: context.l10n.recentTypeAlbum,
|
||||
coverUrl: album.imageUrl,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
showDivider: showDivider,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -732,86 +705,23 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasValidImage =
|
||||
playlist.imageUrl != null &&
|
||||
playlist.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(playlist.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
|
||||
? CachedCoverImage(
|
||||
imageUrl: playlist.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.playlist_play,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
playlist.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
playlist.owner.isNotEmpty
|
||||
? playlist.owner
|
||||
: context.l10n.recentTypePlaylist,
|
||||
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),
|
||||
),
|
||||
],
|
||||
return _SearchResultRowItem(
|
||||
imageUrl: playlist.imageUrl,
|
||||
coverBorderRadius: 10,
|
||||
fallbackIcon: Icons.playlist_play,
|
||||
title: playlist.name,
|
||||
subtitle: Text(
|
||||
playlist.owner.isNotEmpty
|
||||
? playlist.owner
|
||||
: context.l10n.recentTypePlaylist,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
showDivider: showDivider,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -935,6 +845,71 @@ class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy `duration_ms` parsing used by extension album/playlist/artist track
|
||||
/// parsing, kept separate from [extractDurationMs] which also falls back to
|
||||
/// a `duration` (seconds) field these call sites intentionally ignore.
|
||||
int _legacyTrackDurationMs(Map<String, dynamic> data) {
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) return durationValue;
|
||||
if (durationValue is double) return durationValue.toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Prefers the track's own cover, falling back to the parent
|
||||
/// album/playlist cover, without URL validation.
|
||||
String? _resolveTrackCoverUrl(String? trackCover, String? fallbackCover) {
|
||||
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
|
||||
return fallbackCover;
|
||||
}
|
||||
|
||||
/// Shared loading/error+retry scaffold for extension album/playlist/artist
|
||||
/// detail screens.
|
||||
class _LoadingOrErrorScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final Widget loadingBody;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _LoadingOrErrorScaffold({
|
||||
required this.title,
|
||||
required this.isLoading,
|
||||
required this.error,
|
||||
required this.loadingBody,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: loadingBody,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: onRetry,
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
@@ -1059,86 +1034,48 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
String? albumTypeFallback,
|
||||
int? totalTracksFallback,
|
||||
}) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
final base = Track.fromBackendMap(data, source: widget.extensionId);
|
||||
return Track(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
name: base.name,
|
||||
artistName: base.artistName,
|
||||
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
||||
albumArtist: normalizeOptionalString(data['album_artist']?.toString()),
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
coverUrl: _resolveCoverUrl(
|
||||
artistId: base.artistId ?? _artistId,
|
||||
albumId: base.albumId ?? widget.albumId,
|
||||
coverUrl: _resolveTrackCoverUrl(
|
||||
data['cover_url']?.toString(),
|
||||
widget.coverUrl,
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
albumType:
|
||||
normalizeOptionalString(data['album_type']?.toString()) ??
|
||||
albumTypeFallback ??
|
||||
_albumType,
|
||||
totalTracks:
|
||||
data['total_tracks'] as int? ??
|
||||
totalTracksFallback ??
|
||||
_albumTotalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
previewUrl: data['preview_url']?.toString(),
|
||||
explicit: parseExplicitFlag(data['explicit']),
|
||||
isrc: base.isrc,
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
trackNumber: base.trackNumber,
|
||||
discNumber: base.discNumber,
|
||||
totalDiscs: base.totalDiscs,
|
||||
releaseDate: base.releaseDate,
|
||||
albumType: base.albumType ?? albumTypeFallback ?? _albumType,
|
||||
totalTracks: base.totalTracks ?? totalTracksFallback ?? _albumTotalTracks,
|
||||
composer: base.composer,
|
||||
source: base.source,
|
||||
audioQuality: base.audioQuality,
|
||||
audioModes: base.audioModes,
|
||||
previewUrl: base.previewUrl,
|
||||
explicit: base.explicit,
|
||||
);
|
||||
}
|
||||
|
||||
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 AlbumTrackListSkeleton(
|
||||
if (_isLoading || _error != null) {
|
||||
return _LoadingOrErrorScaffold(
|
||||
title: widget.albumName,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
loadingBody: const AlbumTrackListSkeleton(
|
||||
itemCount: 10,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onRetry: _fetchTracks,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1239,74 +1176,46 @@ class _ExtensionPlaylistScreenState
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
final base = Track.fromBackendMap(data, source: widget.extensionId);
|
||||
return Track(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
name: base.name,
|
||||
artistName: base.artistName,
|
||||
albumName: (data['album_name'] ?? '').toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: _resolveCoverUrl(
|
||||
artistId: base.artistId,
|
||||
albumId: base.albumId,
|
||||
coverUrl: _resolveTrackCoverUrl(
|
||||
data['cover_url']?.toString(),
|
||||
widget.coverUrl,
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
previewUrl: data['preview_url']?.toString(),
|
||||
explicit: parseExplicitFlag(data['explicit']),
|
||||
isrc: base.isrc,
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
trackNumber: base.trackNumber,
|
||||
discNumber: base.discNumber,
|
||||
totalDiscs: base.totalDiscs,
|
||||
releaseDate: base.releaseDate,
|
||||
totalTracks: base.totalTracks,
|
||||
composer: base.composer,
|
||||
source: base.source,
|
||||
audioQuality: base.audioQuality,
|
||||
audioModes: base.audioModes,
|
||||
previewUrl: base.previewUrl,
|
||||
explicit: base.explicit,
|
||||
);
|
||||
}
|
||||
|
||||
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 TrackListSkeleton(itemCount: 8, showCoverHeader: true),
|
||||
);
|
||||
}
|
||||
|
||||
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: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isLoading || _error != null) {
|
||||
return _LoadingOrErrorScaffold(
|
||||
title: widget.playlistName,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
loadingBody: const TrackListSkeleton(
|
||||
itemCount: 8,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
onRetry: _fetchTracks,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1427,68 +1336,38 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
final base = Track.fromBackendMap(data);
|
||||
return Track(
|
||||
id: (data['id'] ?? data['spotify_id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
name: base.name,
|
||||
artistName: base.artistName,
|
||||
albumName: base.albumName,
|
||||
albumArtist: base.albumArtist,
|
||||
artistId: base.artistId ?? widget.artistId,
|
||||
albumId: base.albumId,
|
||||
coverUrl: base.coverUrl,
|
||||
isrc: base.isrc,
|
||||
duration: (_legacyTrackDurationMs(data) / 1000).round(),
|
||||
trackNumber: base.trackNumber,
|
||||
discNumber: base.discNumber,
|
||||
totalDiscs: base.totalDiscs,
|
||||
releaseDate: base.releaseDate,
|
||||
totalTracks: base.totalTracks,
|
||||
composer: base.composer,
|
||||
source: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
previewUrl: data['preview_url']?.toString(),
|
||||
previewUrl: base.previewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.artistName)),
|
||||
body: const ArtistScreenSkeleton(),
|
||||
);
|
||||
}
|
||||
|
||||
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: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isLoading || _error != null) {
|
||||
return _LoadingOrErrorScaffold(
|
||||
title: widget.artistName,
|
||||
isLoading: _isLoading,
|
||||
error: _error,
|
||||
loadingBody: const ArtistScreenSkeleton(),
|
||||
onRetry: _fetchArtist,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -14,7 +14,6 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
@@ -105,10 +104,6 @@ class _LibraryTracksFolderScreenState
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isCoverLocalPath(String url) {
|
||||
return !url.startsWith('http://') && !url.startsWith('https://');
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String key) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
@@ -555,31 +550,15 @@ class _LibraryTracksFolderScreenState
|
||||
errorBuilder: (_, _, _) => coverFallback,
|
||||
)
|
||||
: hasCoverUrl
|
||||
? _isCoverLocalPath(coverUrl)
|
||||
? Image.file(
|
||||
File(coverUrl),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
filterQuality: FilterQuality.low,
|
||||
gaplessPlayback: true,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) {
|
||||
return child;
|
||||
}
|
||||
return Container(color: colorScheme.surface);
|
||||
},
|
||||
errorBuilder: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
? LocalOrNetworkCoverImage(
|
||||
url: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
localCacheWidth: cacheWidth,
|
||||
networkCacheWidth: cacheWidth,
|
||||
fadeInDuration: Duration.zero,
|
||||
urlTransform: (u) => highResCoverUrl(u) ?? u,
|
||||
placeholder: (_) => Container(color: colorScheme.surface),
|
||||
)
|
||||
: coverFallback;
|
||||
|
||||
return AlbumDetailHeader(
|
||||
@@ -603,27 +582,16 @@ class _LibraryTracksFolderScreenState
|
||||
errorBuilder: (_, _, _) => squarePlaceholder(),
|
||||
);
|
||||
}
|
||||
if (hasCoverUrl && _isCoverLocalPath(coverUrl)) {
|
||||
return Image.file(
|
||||
File(coverUrl),
|
||||
fit: BoxFit.cover,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => squarePlaceholder(),
|
||||
);
|
||||
}
|
||||
if (hasCoverUrl) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: highResCoverUrl(coverUrl) ?? coverUrl,
|
||||
return LocalOrNetworkCoverImage(
|
||||
url: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => squarePlaceholder(),
|
||||
errorWidget: (_, _, _) => squarePlaceholder(),
|
||||
localCacheWidth: cacheWidth,
|
||||
networkCacheWidth: cacheWidth,
|
||||
urlTransform: (u) => highResCoverUrl(u) ?? u,
|
||||
placeholder: (_) => squarePlaceholder(),
|
||||
);
|
||||
}
|
||||
return squarePlaceholder();
|
||||
@@ -1229,8 +1197,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildTrackCover(BuildContext context, String coverUrl, double size) {
|
||||
final isLocal =
|
||||
!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://');
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
Widget placeholder() => Container(
|
||||
width: size,
|
||||
@@ -1239,47 +1205,14 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
|
||||
if (isLocal) {
|
||||
return Image.file(
|
||||
File(coverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded) return child;
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
placeholder(),
|
||||
AnimatedOpacity(
|
||||
opacity: frame == null ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
return LocalOrNetworkCoverImage(
|
||||
url: coverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (size * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
networkCacheWidth: (size * 2).toInt(),
|
||||
fadeInDuration: const Duration(milliseconds: 180),
|
||||
fadeOutDuration: const Duration(milliseconds: 90),
|
||||
placeholder: (_, _) => placeholder(),
|
||||
errorWidget: (_, _, _) => placeholder(),
|
||||
placeholder: (_) => placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -10,11 +8,11 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
final _log = AppLogger('NowPlaying');
|
||||
@@ -259,7 +257,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
child: SizedBox(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
child: _Artwork(
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth:
|
||||
@@ -1077,53 +1075,3 @@ class _QualityBadge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Artwork extends StatelessWidget {
|
||||
final String? artUri;
|
||||
final ColorScheme colorScheme;
|
||||
final int? cacheWidth;
|
||||
|
||||
const _Artwork({
|
||||
required this.artUri,
|
||||
required this.colorScheme,
|
||||
this.cacheWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholder = Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 40,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
final uri = artUri;
|
||||
if (uri == null || uri.isEmpty) return placeholder;
|
||||
|
||||
if (uri.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
memCacheWidth: cacheWidth,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeOutDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
if (uri.startsWith('file://')) {
|
||||
final path = Uri.parse(uri).toFilePath();
|
||||
return Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,36 +424,15 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
|
||||
if (firstCoverUrl != null) {
|
||||
// Guard against local file paths that may have been stored as coverUrl
|
||||
final isLocalPath =
|
||||
!firstCoverUrl.startsWith('http://') &&
|
||||
!firstCoverUrl.startsWith('https://');
|
||||
if (isLocalPath) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.file(
|
||||
File(firstCoverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheExtent,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return placeholder;
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
return CachedCoverImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
return LocalOrNetworkCoverImage(
|
||||
url: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
memCacheWidth: cacheExtent,
|
||||
borderRadius: borderRadius,
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
localCacheWidth: cacheExtent,
|
||||
networkCacheWidth: cacheExtent,
|
||||
fadeInDuration: Duration.zero,
|
||||
placeholder: (_) => placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
|
||||
|
||||
@@ -57,7 +58,7 @@ class _DownloadFallbackExtensionsPageState
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
final shouldPop = await showDiscardChangesDialog(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
@@ -72,7 +73,7 @@ class _DownloadFallbackExtensionsPageState
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
final shouldPop = await showDiscardChangesDialog(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
@@ -172,27 +173,6 @@ class _DownloadFallbackExtensionsPageState
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
void _saveChanges() {
|
||||
final allExtensionIds = _extensions
|
||||
.map((extension) => extension.id)
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
import 'package:spotiflac_android/widgets/reorderable_priority_item.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/constants/music_services.dart';
|
||||
|
||||
@@ -66,7 +68,10 @@ class _LyricsProviderPriorityPageState
|
||||
description: context.l10n.lyricsProvidersDescription,
|
||||
infoText: context.l10n.lyricsProvidersInfoText,
|
||||
onSave: _saveChanges,
|
||||
onConfirmDiscard: _confirmDiscard,
|
||||
onConfirmDiscard: (ctx) => showDiscardChangesDialog(
|
||||
ctx,
|
||||
content: ctx.l10n.lyricsProvidersDiscardContent,
|
||||
),
|
||||
slivers: [
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
@@ -84,13 +89,23 @@ class _LyricsProviderPriorityPageState
|
||||
itemBuilder: (context, index) {
|
||||
final id = _enabledProviders[index];
|
||||
final info = _getLyricsProviderInfo(id, context);
|
||||
return _EnabledProviderItem(
|
||||
return ReorderablePriorityItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
onToggle: () => _disableProvider(id),
|
||||
icon: info.icon,
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
name: info.name,
|
||||
subtitle: info.description,
|
||||
trailing: SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: true,
|
||||
onChanged: (_) => _disableProvider(id),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
@@ -160,27 +175,6 @@ class _LyricsProviderPriorityPageState
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.lyricsProvidersSaved)));
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(context.l10n.lyricsProvidersDiscardContent),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
static _LyricsProviderInfo _getLyricsProviderInfo(
|
||||
String id,
|
||||
BuildContext context,
|
||||
@@ -262,105 +256,6 @@ class _LyricsProviderPriorityPageState
|
||||
}
|
||||
}
|
||||
|
||||
class _EnabledProviderItem extends StatelessWidget {
|
||||
final String providerId;
|
||||
final _LyricsProviderInfo info;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _EnabledProviderItem({
|
||||
super.key,
|
||||
required this.providerId,
|
||||
required this.info,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(info.icon, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(value: true, onChanged: (_) => onToggle()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DisabledProviderItem extends StatelessWidget {
|
||||
final String providerId;
|
||||
final _LyricsProviderInfo info;
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
import 'package:spotiflac_android/widgets/reorderable_priority_item.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const MetadataProviderPriorityPage({super.key});
|
||||
@@ -52,7 +54,7 @@ class _MetadataProviderPriorityPageState
|
||||
infoText: context.l10n.metadataProviderPriorityInfo,
|
||||
saveLabel: context.l10n.dialogSave,
|
||||
onSave: _saveChanges,
|
||||
onConfirmDiscard: _confirmDiscard,
|
||||
onConfirmDiscard: showDiscardChangesDialog,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
@@ -60,17 +62,19 @@ class _MetadataProviderPriorityPageState
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _MetadataProviderItem(
|
||||
final extension = ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull;
|
||||
return ReorderablePriorityItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
extension: ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull,
|
||||
icon: Icons.extension,
|
||||
iconColor: Theme.of(context).colorScheme.secondary,
|
||||
name: extension?.displayName ?? provider,
|
||||
subtitle: context.l10n.providerExtension,
|
||||
);
|
||||
},
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
@@ -86,27 +90,6 @@ class _MetadataProviderPriorityPageState
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
@@ -120,121 +103,3 @@ class _MetadataProviderPriorityPageState
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataProviderItem extends StatelessWidget {
|
||||
final String provider;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final Extension? extension;
|
||||
|
||||
const _MetadataProviderItem({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
this.extension,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(context, provider, extension);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(info.icon, color: colorScheme.secondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_MetadataProviderInfo _getProviderInfo(
|
||||
BuildContext context,
|
||||
String provider,
|
||||
Extension? extension,
|
||||
) {
|
||||
return _MetadataProviderInfo(
|
||||
name: extension?.displayName ?? provider,
|
||||
icon: Icons.extension,
|
||||
description: context.l10n.providerExtension,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final String description;
|
||||
|
||||
_MetadataProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.description,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/discard_changes_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
import 'package:spotiflac_android/widgets/reorderable_priority_item.dart';
|
||||
|
||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
@@ -43,146 +45,49 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SettingsSliverAppBar(
|
||||
title: context.l10n.providerPriorityTitle,
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.l10n.providerPriorityDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _ProviderItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
extension: ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull,
|
||||
);
|
||||
},
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.providerPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
return PrioritySettingsScaffold(
|
||||
hasChanges: _hasChanges,
|
||||
title: context.l10n.providerPriorityTitle,
|
||||
description: context.l10n.providerPriorityDescription,
|
||||
descriptionPadding: const EdgeInsets.all(16),
|
||||
infoText: context.l10n.providerPriorityInfo,
|
||||
onSave: _saveChanges,
|
||||
onConfirmDiscard: showDiscardChangesDialog,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
final extension = ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull;
|
||||
return ReorderablePriorityItem(
|
||||
key: ValueKey(provider),
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
icon: Icons.extension,
|
||||
iconColor: Theme.of(context).colorScheme.secondary,
|
||||
name: extension?.displayName ?? provider,
|
||||
subtitle: context.l10n.providerExtension,
|
||||
);
|
||||
},
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setProviderPriority(_providers);
|
||||
if (!mounted) return;
|
||||
@@ -194,111 +99,3 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderItem extends StatelessWidget {
|
||||
final String provider;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final Extension? extension;
|
||||
|
||||
const _ProviderItem({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
this.extension,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(provider, extension);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(info.icon, color: colorScheme.secondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.providerExtension,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider, Extension? extension) {
|
||||
return _ProviderInfo(
|
||||
name: extension?.displayName ?? provider,
|
||||
icon: Icons.extension,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
|
||||
_ProviderInfo({required this.name, required this.icon});
|
||||
}
|
||||
|
||||
@@ -241,21 +241,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
|
||||
formats.addAll(['AAC', 'MP3', 'Opus']);
|
||||
}
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
|
||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||
int? selectedMaxBitDepth;
|
||||
int? selectedMaxSampleRate;
|
||||
String selectedDither = 'none';
|
||||
String selectedResampler = 'swr';
|
||||
final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth);
|
||||
final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate);
|
||||
final labels = context.l10n.losslessConversionLabels;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -264,438 +250,43 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final labels = context.l10n.losslessConversionLabels;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
Widget card({required Widget child}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: settingsGroupColor(context),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget sectionLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget choice({
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: selected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 11,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.transparent
|
||||
: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: context.l10n.trackConvertTitle,
|
||||
subtitle: currentFormat,
|
||||
sourceIsLossless: isLosslessSource,
|
||||
sourceBitDepth: bitDepth,
|
||||
sourceSampleRate: sampleRate,
|
||||
confirmLabelBuilder:
|
||||
(format, bitrate, isLosslessTarget, losslessQuality) {
|
||||
return isLosslessTarget
|
||||
? context.l10n.trackConvertActionLabelLossless(
|
||||
currentFormat,
|
||||
format,
|
||||
losslessQualityLabel(
|
||||
losslessQuality,
|
||||
originalLabel: labels.original,
|
||||
originalQualityLabel: labels.originalQuality,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: DraggableScrollableSheet(
|
||||
initialChildSize: 0.85,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
context.l10n.trackConvertTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentFormat,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: formats.map((format) {
|
||||
return choice(
|
||||
label: format,
|
||||
selected: format == selectedFormat,
|
||||
onTap: () {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
isLosslessConversionTarget(format);
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate =
|
||||
defaultBitrateForFormat(format);
|
||||
} else {
|
||||
selectedMaxBitDepth = null;
|
||||
selectedMaxSampleRate = null;
|
||||
selectedDither = 'none';
|
||||
selectedResampler = 'swr';
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!isLosslessTarget)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(context.l10n.trackConvertBitrate),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
return choice(
|
||||
label: br,
|
||||
selected: br == selectedBitrate,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedBitrate = br,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && bitDepthOptions.isNotEmpty)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(
|
||||
context.l10n.audioAnalysisBitDepth,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
choice(
|
||||
label: losslessBitDepthLabel(
|
||||
null,
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: selectedMaxBitDepth == null,
|
||||
onTap: () => setSheetState(() {
|
||||
selectedMaxBitDepth = null;
|
||||
selectedDither = 'none';
|
||||
}),
|
||||
),
|
||||
...bitDepthOptions.map((depth) {
|
||||
return choice(
|
||||
label: losslessBitDepthLabel(
|
||||
depth,
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: depth == selectedMaxBitDepth,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedMaxBitDepth = depth,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(
|
||||
context.l10n.audioAnalysisSampleRate,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
choice(
|
||||
label: losslessSampleRateLabel(
|
||||
null,
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: selectedMaxSampleRate == null,
|
||||
onTap: () => setSheetState(() {
|
||||
selectedMaxSampleRate = null;
|
||||
selectedResampler = 'swr';
|
||||
}),
|
||||
),
|
||||
...sampleRateOptions.map((rate) {
|
||||
return choice(
|
||||
label: losslessSampleRateLabel(
|
||||
rate,
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: rate == selectedMaxSampleRate,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedMaxSampleRate = rate,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && selectedMaxBitDepth != null)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(
|
||||
context.l10n.trackConvertDithering,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: losslessDitherOptions.map((mode) {
|
||||
return choice(
|
||||
label: context.l10n
|
||||
.losslessDitherOptionLabel(mode),
|
||||
selected: mode == selectedDither,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedDither = mode,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && selectedMaxSampleRate != null)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(
|
||||
context.l10n.trackConvertResampler,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: losslessResamplerOptions.map((
|
||||
mode,
|
||||
) {
|
||||
return choice(
|
||||
label: context.l10n
|
||||
.losslessResamplerOptionLabel(mode),
|
||||
selected: mode == selectedResampler,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedResampler = mode,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && isLosslessSource)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedMaxBitDepth == null &&
|
||||
selectedMaxSampleRate == null
|
||||
? context.l10n.trackConvertLosslessHint
|
||||
: context.l10n
|
||||
.trackConvertLosslessOutputWithCap(
|
||||
losslessQualityLabel(
|
||||
LosslessConversionQuality(
|
||||
maxBitDepth:
|
||||
selectedMaxBitDepth,
|
||||
maxSampleRate:
|
||||
selectedMaxSampleRate,
|
||||
),
|
||||
originalLabel:
|
||||
labels.original,
|
||||
originalQualityLabel:
|
||||
labels.originalQuality,
|
||||
),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_confirmAndConvert(
|
||||
context: this.context,
|
||||
sourceFormat: currentFormat,
|
||||
targetFormat: selectedFormat,
|
||||
bitrate: selectedBitrate,
|
||||
losslessQuality: LosslessConversionQuality(
|
||||
maxBitDepth: selectedMaxBitDepth,
|
||||
maxSampleRate: selectedMaxSampleRate,
|
||||
),
|
||||
losslessProcessing:
|
||||
LosslessConversionProcessing(
|
||||
dither: selectedDither,
|
||||
resampler: selectedResampler,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
isLosslessTarget
|
||||
? context.l10n
|
||||
.trackConvertActionLabelLossless(
|
||||
currentFormat,
|
||||
selectedFormat,
|
||||
losslessQualityLabel(
|
||||
LosslessConversionQuality(
|
||||
maxBitDepth: selectedMaxBitDepth,
|
||||
maxSampleRate:
|
||||
selectedMaxSampleRate,
|
||||
),
|
||||
originalLabel: labels.original,
|
||||
originalQualityLabel:
|
||||
labels.originalQuality,
|
||||
),
|
||||
)
|
||||
: context.l10n.trackConvertActionLabelLossy(
|
||||
currentFormat,
|
||||
selectedFormat,
|
||||
selectedBitrate,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
: context.l10n.trackConvertActionLabelLossy(
|
||||
currentFormat,
|
||||
format,
|
||||
bitrate,
|
||||
);
|
||||
},
|
||||
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||
Navigator.pop(sheetContext);
|
||||
_confirmAndConvert(
|
||||
context: this.context,
|
||||
sourceFormat: currentFormat,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/constants/music_services.dart';
|
||||
|
||||
@@ -9,7 +9,15 @@ class BatchConvertSheet extends StatefulWidget {
|
||||
final List<String> formats;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String confirmLabel;
|
||||
final String? confirmLabel;
|
||||
final String Function(
|
||||
String format,
|
||||
String bitrate,
|
||||
bool isLosslessTarget,
|
||||
LosslessConversionQuality losslessQuality,
|
||||
)?
|
||||
confirmLabelBuilder;
|
||||
final bool sourceIsLossless;
|
||||
final int? sourceBitDepth;
|
||||
final int? sourceSampleRate;
|
||||
final void Function(
|
||||
@@ -24,8 +32,10 @@ class BatchConvertSheet extends StatefulWidget {
|
||||
super.key,
|
||||
required this.formats,
|
||||
required this.title,
|
||||
required this.confirmLabel,
|
||||
required this.onConvert,
|
||||
this.confirmLabel,
|
||||
this.confirmLabelBuilder,
|
||||
this.sourceIsLossless = true,
|
||||
this.subtitle,
|
||||
this.sourceBitDepth,
|
||||
this.sourceSampleRate,
|
||||
@@ -317,7 +327,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLosslessTarget)
|
||||
if (_isLosslessTarget && widget.sourceIsLossless)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -383,7 +393,18 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
label: Text(widget.confirmLabel),
|
||||
label: Text(
|
||||
widget.confirmLabelBuilder?.call(
|
||||
_selectedFormat,
|
||||
_selectedBitrate,
|
||||
_isLosslessTarget,
|
||||
LosslessConversionQuality(
|
||||
maxBitDepth: _selectedMaxBitDepth,
|
||||
maxSampleRate: _selectedMaxSampleRate,
|
||||
),
|
||||
) ??
|
||||
widget.confirmLabel!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
@@ -83,6 +85,94 @@ class CachedCoverImage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders [url] as a local file (when it's not an http/https URL) or as a
|
||||
/// cached network image otherwise, with a shared [placeholder] used for the
|
||||
/// local error state, the local not-ready frame, and the network
|
||||
/// placeholder/error states alike.
|
||||
class LocalOrNetworkCoverImage extends StatelessWidget {
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final BorderRadius? borderRadius;
|
||||
final int? localCacheWidth;
|
||||
final int? networkCacheWidth;
|
||||
final Duration? fadeInDuration;
|
||||
final Duration fadeOutDuration;
|
||||
final String Function(String)? urlTransform;
|
||||
final Widget Function(BuildContext) placeholder;
|
||||
|
||||
const LocalOrNetworkCoverImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.borderRadius,
|
||||
this.localCacheWidth,
|
||||
this.networkCacheWidth,
|
||||
this.fadeInDuration,
|
||||
this.fadeOutDuration = Duration.zero,
|
||||
this.urlTransform,
|
||||
required this.placeholder,
|
||||
});
|
||||
|
||||
bool get _isLocal =>
|
||||
!url.startsWith('http://') && !url.startsWith('https://');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLocal) {
|
||||
final image = Image.file(
|
||||
File(url),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
cacheWidth: localCacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: fadeInDuration == null
|
||||
? null
|
||||
: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
final ready = wasSynchronouslyLoaded || frame != null;
|
||||
if (fadeInDuration == Duration.zero) {
|
||||
return ready ? child : placeholder(context);
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
placeholder(context),
|
||||
AnimatedOpacity(
|
||||
opacity: ready ? 1.0 : 0.0,
|
||||
duration: fadeInDuration!,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder(context),
|
||||
);
|
||||
return borderRadius == null
|
||||
? image
|
||||
: ClipRRect(borderRadius: borderRadius!, child: image);
|
||||
}
|
||||
|
||||
return CachedCoverImage(
|
||||
imageUrl: urlTransform?.call(url) ?? url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
memCacheWidth: networkCacheWidth,
|
||||
borderRadius: borderRadius,
|
||||
fadeInDuration: fadeInDuration ?? Duration.zero,
|
||||
fadeOutDuration: fadeOutDuration,
|
||||
placeholder: (_, _) => placeholder(context),
|
||||
errorWidget: (_, _, _) => placeholder(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider cachedCoverImageProvider(String url) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Shared discard-unsaved-changes confirmation dialog used by priority /
|
||||
/// selection settings pages.
|
||||
Future<bool> showDiscardChangesDialog(
|
||||
BuildContext context, {
|
||||
String? content,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(content ?? context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class MiniPlayer extends ConsumerWidget {
|
||||
@@ -62,9 +59,11 @@ class MiniPlayer extends ConsumerWidget {
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: _MiniArt(
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth: 132,
|
||||
iconSize: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -109,45 +108,3 @@ class MiniPlayer extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniArt extends StatelessWidget {
|
||||
final String? artUri;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _MiniArt({required this.artUri, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholder = Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 22,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
final uri = artUri;
|
||||
if (uri == null || uri.isEmpty) return placeholder;
|
||||
if (uri.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
memCacheWidth: 132,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeOutDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
if (uri.startsWith('file://')) {
|
||||
return Image.file(
|
||||
File(Uri.parse(uri).toFilePath()),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 132,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
class PlayerArtwork extends StatelessWidget {
|
||||
final String? artUri;
|
||||
final ColorScheme colorScheme;
|
||||
final int? cacheWidth;
|
||||
final double iconSize;
|
||||
|
||||
const PlayerArtwork({
|
||||
super.key,
|
||||
required this.artUri,
|
||||
required this.colorScheme,
|
||||
this.cacheWidth,
|
||||
this.iconSize = 40,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholder = Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
final uri = artUri;
|
||||
if (uri == null || uri.isEmpty) return placeholder;
|
||||
|
||||
if (uri.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
memCacheWidth: cacheWidth,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeOutDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
if (uri.startsWith('file://')) {
|
||||
final path = Uri.parse(uri).toFilePath();
|
||||
return Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
Future<void> showAddTrackToPlaylistSheet(
|
||||
BuildContext context,
|
||||
@@ -383,29 +382,12 @@ class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
|
||||
final firstCoverUrl = playlist.previewCover;
|
||||
if (firstCoverUrl != null) {
|
||||
final isLocalPath =
|
||||
!firstCoverUrl.startsWith('http://') &&
|
||||
!firstCoverUrl.startsWith('https://');
|
||||
|
||||
if (isLocalPath) {
|
||||
return Image.file(
|
||||
File(firstCoverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
return LocalOrNetworkCoverImage(
|
||||
url: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (size * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => _iconFallback(colorScheme, size),
|
||||
errorWidget: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
networkCacheWidth: (size * 2).toInt(),
|
||||
placeholder: (_) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared numbered, draggable row used by provider-priority reorder lists.
|
||||
class ReorderablePriorityItem extends StatelessWidget {
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String name;
|
||||
final String subtitle;
|
||||
final Widget? trailing;
|
||||
|
||||
const ReorderablePriorityItem({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.name,
|
||||
required this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(icon, color: iconColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[trailing!, const SizedBox(width: 4)],
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user