mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-19 22:54:43 +02:00
Add CSV import and optimize Appearance settings
This commit is contained in:
@@ -525,6 +525,12 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
// Also get audio quality info
|
||||
quality, qualityErr := GetAudioQuality(filePath)
|
||||
|
||||
// Get duration from FLAC stream info
|
||||
duration := 0
|
||||
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"title": metadata.Title,
|
||||
"artist": metadata.Artist,
|
||||
@@ -535,6 +541,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
"disc_number": metadata.DiscNumber,
|
||||
"isrc": metadata.ISRC,
|
||||
"lyrics": metadata.Lyrics,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
// Add quality info if available
|
||||
@@ -980,7 +987,7 @@ func errorResponse(msg string) (string, error) {
|
||||
errorType := "unknown"
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
|
||||
+60
-52
@@ -58,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
@@ -66,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
|
||||
if metadata.Description != "" {
|
||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
@@ -162,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
@@ -170,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
|
||||
if metadata.Description != "" {
|
||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
@@ -276,7 +276,7 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Try DATE variants
|
||||
if metadata.Date == "" {
|
||||
metadata.Date = getComment(cmt, "YEAR")
|
||||
@@ -380,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Try LYRICS tag first
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
|
||||
|
||||
// Fallback to UNSYNCEDLYRICS
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
@@ -400,8 +400,9 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
|
||||
// AudioQuality represents audio quality info from a FLAC file
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
}
|
||||
|
||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||
@@ -419,7 +420,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
if _, err := file.Read(marker); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Check if it's a FLAC file
|
||||
if string(marker) == "fLaC" {
|
||||
// Continue reading FLAC metadata
|
||||
@@ -446,12 +447,20 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
// Parse bits per sample (5 bits)
|
||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||
|
||||
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
|
||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||
int64(streamInfo[14])<<24 |
|
||||
int64(streamInfo[15])<<16 |
|
||||
int64(streamInfo[16])<<8 |
|
||||
int64(streamInfo[17])
|
||||
|
||||
return AudioQuality{
|
||||
BitDepth: bitsPerSample,
|
||||
SampleRate: sampleRate,
|
||||
BitDepth: bitsPerSample,
|
||||
SampleRate: sampleRate,
|
||||
TotalSamples: totalSamples,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
||||
// First 4 bytes are size, next 4 should be "ftyp"
|
||||
file.Seek(0, 0) // Reset to beginning
|
||||
@@ -459,17 +468,16 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
if _, err := file.Read(header8); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
|
||||
if string(header8[4:8]) == "ftyp" {
|
||||
// It's an M4A/MP4 file, use M4A quality reader
|
||||
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||
return GetM4AQuality(filePath)
|
||||
}
|
||||
|
||||
|
||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// M4A (MP4/AAC) Metadata Embedding
|
||||
// ========================================
|
||||
@@ -492,16 +500,16 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
// Find udta atom inside moov, or create one
|
||||
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||
|
||||
|
||||
// Build new metadata atoms
|
||||
metaAtom := buildMetaAtom(metadata, coverData)
|
||||
|
||||
|
||||
var newData []byte
|
||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||
// udta exists, find meta inside it or replace
|
||||
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||
|
||||
|
||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||
// Replace existing meta atom
|
||||
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
||||
@@ -519,7 +527,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
newUdta[3] = byte(newUdtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, newUdtaContent...)
|
||||
|
||||
|
||||
newData = append(newData, data[:udtaPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||
@@ -535,14 +543,14 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
newUdta[3] = byte(udtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, udtaContent...)
|
||||
|
||||
|
||||
// Insert udta at end of moov
|
||||
insertPos := moovPos + moovSize
|
||||
newData = append(newData, data[:insertPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[insertPos:]...)
|
||||
}
|
||||
|
||||
|
||||
// Update moov size
|
||||
newMoovSize := moovSize + len(newData) - len(data)
|
||||
newData[moovPos] = byte(newMoovSize >> 24)
|
||||
@@ -579,52 +587,52 @@ func findAtom(data []byte, name string, offset int) int {
|
||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
// Build ilst content
|
||||
var ilst []byte
|
||||
|
||||
|
||||
// ©nam - Title
|
||||
if metadata.Title != "" {
|
||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||
}
|
||||
|
||||
|
||||
// ©ART - Artist
|
||||
if metadata.Artist != "" {
|
||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||
}
|
||||
|
||||
|
||||
// ©alb - Album
|
||||
if metadata.Album != "" {
|
||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||
}
|
||||
|
||||
|
||||
// aART - Album Artist
|
||||
if metadata.AlbumArtist != "" {
|
||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||
}
|
||||
|
||||
|
||||
// ©day - Year/Date
|
||||
if metadata.Date != "" {
|
||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||
}
|
||||
|
||||
|
||||
// trkn - Track Number
|
||||
if metadata.TrackNumber > 0 {
|
||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||
}
|
||||
|
||||
|
||||
// disk - Disc Number
|
||||
if metadata.DiscNumber > 0 {
|
||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||
}
|
||||
|
||||
|
||||
// ©lyr - Lyrics
|
||||
if metadata.Lyrics != "" {
|
||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||
}
|
||||
|
||||
|
||||
// covr - Cover Art
|
||||
if len(coverData) > 0 {
|
||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||
}
|
||||
|
||||
|
||||
// Build ilst atom
|
||||
ilstSize := 8 + len(ilst)
|
||||
ilstAtom := make([]byte, 4)
|
||||
@@ -634,7 +642,7 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
ilstAtom[3] = byte(ilstSize)
|
||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||
ilstAtom = append(ilstAtom, ilst...)
|
||||
|
||||
|
||||
// Build hdlr atom (required for meta)
|
||||
hdlr := []byte{
|
||||
0, 0, 0, 33, // size = 33
|
||||
@@ -647,11 +655,11 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
0, 0, 0, 0, // component flags mask
|
||||
0, // null terminator
|
||||
}
|
||||
|
||||
|
||||
// Build meta atom
|
||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||
metaContent = append(metaContent, ilstAtom...)
|
||||
|
||||
|
||||
metaSize := 8 + len(metaContent)
|
||||
metaAtom := make([]byte, 4)
|
||||
metaAtom[0] = byte(metaSize >> 24)
|
||||
@@ -660,14 +668,14 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
metaAtom[3] = byte(metaSize)
|
||||
metaAtom = append(metaAtom, []byte("meta")...)
|
||||
metaAtom = append(metaAtom, metaContent...)
|
||||
|
||||
|
||||
return metaAtom
|
||||
}
|
||||
|
||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||
func buildTextAtom(name, value string) []byte {
|
||||
valueBytes := []byte(value)
|
||||
|
||||
|
||||
// data atom
|
||||
dataSize := 16 + len(valueBytes)
|
||||
dataAtom := make([]byte, 4)
|
||||
@@ -679,7 +687,7 @@ func buildTextAtom(name, value string) []byte {
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, valueBytes...)
|
||||
|
||||
|
||||
// container atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -689,7 +697,7 @@ func buildTextAtom(name, value string) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte(name)...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
@@ -706,7 +714,7 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
byte(total >> 8), byte(total), // total tracks
|
||||
0, 0, // padding
|
||||
}
|
||||
|
||||
|
||||
// trkn atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -716,7 +724,7 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("trkn")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
@@ -732,7 +740,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
byte(disc >> 8), byte(disc), // disc number
|
||||
byte(total >> 8), byte(total), // total discs
|
||||
}
|
||||
|
||||
|
||||
// disk atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -742,7 +750,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("disk")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
@@ -753,7 +761,7 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||
imageType = 14 // PNG
|
||||
}
|
||||
|
||||
|
||||
// data atom
|
||||
dataSize := 16 + len(coverData)
|
||||
dataAtom := make([]byte, 4)
|
||||
@@ -765,7 +773,7 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, coverData...)
|
||||
|
||||
|
||||
// covr atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -775,7 +783,7 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("covr")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+106
-1
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
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/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
|
||||
@@ -266,6 +267,104 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||
// Show loading dialog with progress
|
||||
int currentProgress = 0;
|
||||
int totalTracks = 0;
|
||||
|
||||
// Use StatefulBuilder to update dialog content
|
||||
final dialogContext = context;
|
||||
bool dialogShown = false;
|
||||
StateSetter? setDialogState;
|
||||
|
||||
void showProgressDialog() {
|
||||
if (dialogShown) return;
|
||||
dialogShown = true;
|
||||
showDialog(
|
||||
context: dialogContext,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
setDialogState = setState;
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
totalTracks > 0
|
||||
? 'Fetching metadata... $currentProgress/$totalTracks'
|
||||
: 'Reading CSV...',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final tracks = await CsvImportService.pickAndParseCsv(
|
||||
onProgress: (current, total) {
|
||||
currentProgress = current;
|
||||
totalTracks = total;
|
||||
if (!dialogShown && total > 0) {
|
||||
showProgressDialog();
|
||||
}
|
||||
setDialogState?.call(() {});
|
||||
},
|
||||
);
|
||||
|
||||
// Close progress dialog
|
||||
if (dialogShown && mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
|
||||
if (tracks.isNotEmpty) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// Optionally show confirmation dialog
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Import Playlist'),
|
||||
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added ${tracks.length} tracks to queue'),
|
||||
action: SnackBarAction(
|
||||
label: 'View Queue',
|
||||
onPressed: () {
|
||||
// Navigate to queue tab (handled by main_shell index)
|
||||
// We don't have direct access to set index here easily without provider
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty)
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -770,12 +869,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
onPressed: _clearAndRefresh,
|
||||
tooltip: 'Clear',
|
||||
)
|
||||
else
|
||||
else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_upload_outlined),
|
||||
onPressed: () => _importCsv(context, ref),
|
||||
tooltip: 'Import CSV',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: _pasteFromClipboard,
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
|
||||
@@ -62,6 +62,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
void _handleSharedUrl(String url) {
|
||||
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
|
||||
// Navigate to Home tab
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
|
||||
@@ -27,20 +27,31 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: _AppBarTitle(
|
||||
title: 'Appearance',
|
||||
topPadding: topPadding,
|
||||
),
|
||||
),
|
||||
|
||||
// Preview Section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: _ThemePreviewCard(),
|
||||
),
|
||||
),
|
||||
|
||||
// Color section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Color'),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -50,7 +61,9 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
title: 'Dynamic Color',
|
||||
subtitle: 'Use colors from your wallpaper',
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
onChanged: (value) => ref
|
||||
.read(themeProvider.notifier)
|
||||
.setUseDynamicColor(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -62,47 +75,60 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: _ColorPalettePicker(
|
||||
currentColor: themeSettings.seedColorValue,
|
||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||
onColorSelected: (color) =>
|
||||
ref.read(themeProvider.notifier).setSeedColor(color),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Theme section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Theme'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ThemeModeSelector(
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.brightness_2,
|
||||
title: 'AMOLED Dark',
|
||||
subtitle: 'Pure black background',
|
||||
value: themeSettings.useAmoled,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||
showDivider: false,
|
||||
onChanged: (mode) =>
|
||||
ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
if (Theme.of(context).brightness == Brightness.dark)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.brightness_2,
|
||||
title: 'AMOLED Dark',
|
||||
subtitle: 'Pure black background',
|
||||
value: themeSettings.useAmoled,
|
||||
onChanged: (value) =>
|
||||
ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Layout section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Layout'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_HistoryViewSelector(
|
||||
currentMode: settings.historyViewMode,
|
||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
||||
onChanged: (mode) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setHistoryViewMode(mode),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Fill remaining for scroll
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox(height: 32)),
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: SizedBox(height: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -116,143 +142,180 @@ class _ThemePreviewCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest, // Background similar to reference
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Decorative background blobs
|
||||
Positioned(
|
||||
top: -50,
|
||||
right: -50,
|
||||
child: Container(
|
||||
width: 200, height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme
|
||||
.surfaceContainerHighest, // Background similar to reference
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Decorative background blobs
|
||||
Positioned(
|
||||
top: -50,
|
||||
right: -50,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
child: Container(
|
||||
width: 150, height: 150,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
Positioned(
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Foreground "fake UI"
|
||||
Center(
|
||||
child: Container(
|
||||
width: 260,
|
||||
height: 140,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Fake Album Art
|
||||
Container(
|
||||
width: 108,
|
||||
height: 108,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
||||
// Foreground "fake UI"
|
||||
Center(
|
||||
child: Container(
|
||||
width: 260,
|
||||
height: 140,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 12, // Reduced from 20 for performance
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onPrimary, size: 48),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Fake Text Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity, height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 80, height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.skip_previous, size: 24, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.play_circle_fill, size: 32, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.skip_next, size: 24, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Fake Album Art
|
||||
Container(
|
||||
width: 108,
|
||||
height: 108,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Fake Text Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 80,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.skip_previous,
|
||||
size: 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.play_circle_fill,
|
||||
size: 32,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.skip_next,
|
||||
size: 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Label badge
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isDark ? 'Dark Mode' : 'Light Mode',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Label badge
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isDark ? 'Dark Mode' : 'Light Mode',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class _ColorPalettePicker extends StatelessWidget {
|
||||
final int currentColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
const _ColorPalettePicker({required this.currentColor, required this.onColorSelected});
|
||||
const _ColorPalettePicker({
|
||||
required this.currentColor,
|
||||
required this.onColorSelected,
|
||||
});
|
||||
|
||||
static const _colors = [
|
||||
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
|
||||
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874),
|
||||
Color(0xFF1DB954),
|
||||
Color(0xFF6750A4),
|
||||
Color(0xFF0061A4),
|
||||
Color(0xFF006E1C),
|
||||
Color(0xFFBA1A1A),
|
||||
Color(0xFF984061),
|
||||
Color(0xFF7D5260),
|
||||
Color(0xFF006874),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -278,22 +341,23 @@ class _ColorPalettePicker extends StatelessWidget {
|
||||
class _ColorPaletteItem extends StatelessWidget {
|
||||
final Color color;
|
||||
final bool isSelected;
|
||||
|
||||
|
||||
const _ColorPaletteItem({required this.color, required this.isSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: color, brightness: Theme.of(context).brightness);
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: color,
|
||||
brightness: Theme.of(context).brightness,
|
||||
);
|
||||
final size = 64.0;
|
||||
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -308,7 +372,9 @@ class _ColorPaletteItem extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Container(color: scheme.secondaryContainer)),
|
||||
Expanded(
|
||||
child: Container(color: scheme.secondaryContainer),
|
||||
),
|
||||
Expanded(child: Container(color: scheme.surfaceContainer)),
|
||||
],
|
||||
),
|
||||
@@ -318,16 +384,16 @@ class _ColorPaletteItem extends StatelessWidget {
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.check, size: 16, color: scheme.primary),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.check, size: 16, color: scheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -338,7 +404,7 @@ class _ColorPaletteItem extends StatelessWidget {
|
||||
class _AppBarTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final double topPadding;
|
||||
|
||||
|
||||
const _AppBarTitle({required this.title, required this.topPadding});
|
||||
|
||||
@override
|
||||
@@ -348,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
@@ -370,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
|
||||
class _ThemeModeSelector extends StatelessWidget {
|
||||
final ThemeMode currentMode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
|
||||
const _ThemeModeSelector({
|
||||
required this.currentMode,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(children: [
|
||||
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
_ThemeModeChip(
|
||||
icon: Icons.brightness_auto,
|
||||
label: 'System',
|
||||
isSelected: currentMode == ThemeMode.system,
|
||||
onTap: () => onChanged(ThemeMode.system),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(
|
||||
icon: Icons.light_mode,
|
||||
label: 'Light',
|
||||
isSelected: currentMode == ThemeMode.light,
|
||||
onTap: () => onChanged(ThemeMode.light),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(
|
||||
icon: Icons.dark_mode,
|
||||
label: 'Dark',
|
||||
isSelected: currentMode == ThemeMode.dark,
|
||||
onTap: () => onChanged(ThemeMode.dark),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -392,27 +480,41 @@ class _ThemeModeChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
||||
const _ThemeModeChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
// Unselected chips need contrast with card background
|
||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.05),
|
||||
colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
@@ -423,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -441,7 +559,10 @@ class _ThemeModeChip extends StatelessWidget {
|
||||
class _HistoryViewSelector extends StatelessWidget {
|
||||
final String currentMode;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
|
||||
const _HistoryViewSelector({
|
||||
required this.currentMode,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -453,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
child: Text(
|
||||
'History View',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_ViewModeChip(
|
||||
icon: Icons.view_list,
|
||||
label: 'List',
|
||||
isSelected: currentMode == 'list',
|
||||
onTap: () => onChanged('list'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ViewModeChip(
|
||||
icon: Icons.grid_view,
|
||||
label: 'Grid',
|
||||
isSelected: currentMode == 'grid',
|
||||
onTap: () => onChanged('grid'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(children: [
|
||||
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
|
||||
const SizedBox(width: 8),
|
||||
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -471,25 +609,39 @@ class _ViewModeChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
||||
const _ViewModeChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
// Unselected chips need contrast with card background
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.05),
|
||||
colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
@@ -500,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -442,7 +442,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_MetadataItem('Album', albumName),
|
||||
if (trackNumber != null && trackNumber! > 0)
|
||||
_MetadataItem('Track number', trackNumber.toString()),
|
||||
if (discNumber != null && discNumber! > 1)
|
||||
if (discNumber != null && discNumber! > 0)
|
||||
_MetadataItem('Disc number', discNumber.toString()),
|
||||
if (item.duration != null)
|
||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'dart:io';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
class CsvImportService {
|
||||
static final _log = AppLogger('CsvImportService');
|
||||
|
||||
/// Pick and parse CSV file, then enrich metadata from Deezer
|
||||
/// [onProgress] callback receives (current, total) for progress updates
|
||||
static Future<List<Track>> pickAndParseCsv({
|
||||
void Function(int current, int total)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
final FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final content = await file.readAsString();
|
||||
final tracks = _parseCsv(content);
|
||||
|
||||
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
|
||||
if (tracks.isNotEmpty) {
|
||||
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Error picking/parsing CSV: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Enrich tracks with metadata from Deezer using ISRC
|
||||
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
|
||||
static Future<List<Track>> _enrichTracksMetadata(
|
||||
List<Track> tracks, {
|
||||
void Function(int current, int total)? onProgress,
|
||||
}) async {
|
||||
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
|
||||
final enrichedTracks = <Track>[];
|
||||
|
||||
for (int i = 0; i < tracks.length; i++) {
|
||||
final track = tracks[i];
|
||||
onProgress?.call(i + 1, tracks.length);
|
||||
|
||||
// Only enrich if we have ISRC and missing cover/duration
|
||||
if (track.isrc != null &&
|
||||
track.isrc!.isNotEmpty &&
|
||||
(track.coverUrl == null || track.duration == 0)) {
|
||||
try {
|
||||
// searchDeezerByISRC returns TrackMetadata directly (not wrapped in "track" key)
|
||||
final trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
||||
|
||||
// Extract enriched data from TrackMetadata
|
||||
final coverUrl = trackData['images'] as String?;
|
||||
final durationMs = trackData['duration_ms'] as int? ?? 0;
|
||||
final deezerIdRaw = trackData['spotify_id'] as String?; // Format: "deezer:123456"
|
||||
|
||||
enrichedTracks.add(Track(
|
||||
id: deezerIdRaw ?? track.id, // Use Deezer ID if available
|
||||
name: trackData['name'] as String? ?? track.name,
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
|
||||
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
|
||||
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
|
||||
));
|
||||
|
||||
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
||||
|
||||
// Small delay to avoid rate limiting (50ms between requests)
|
||||
if (i < tracks.length - 1) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
continue;
|
||||
} catch (e) {
|
||||
_log.w('Failed to enrich ${track.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Keep original track if enrichment failed or not needed
|
||||
enrichedTracks.add(track);
|
||||
}
|
||||
|
||||
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
|
||||
return enrichedTracks;
|
||||
}
|
||||
|
||||
static List<Track> _parseCsv(String content) {
|
||||
final List<Track> tracks = [];
|
||||
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
|
||||
if (lines.isEmpty) return tracks;
|
||||
|
||||
// Detect headers line (assume first non-empty line)
|
||||
int startIdx = 0;
|
||||
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
|
||||
startIdx++;
|
||||
}
|
||||
if (startIdx >= lines.length) return tracks;
|
||||
|
||||
final headers = _parseLine(lines[startIdx]);
|
||||
final colMap = <String, int>{};
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
// Normalize header: lowercase, trim, remove quotes
|
||||
String h = _cleanValue(headers[i]).toLowerCase();
|
||||
colMap[h] = i;
|
||||
}
|
||||
|
||||
_log.d('CSV Headers: ${colMap.keys.toList()}');
|
||||
|
||||
// Parse rows
|
||||
for (int i = startIdx + 1; i < lines.length; i++) {
|
||||
final line = lines[i].trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
final values = _parseLine(line);
|
||||
|
||||
// Helper to get value securely
|
||||
String? getVal(List<String> keys) {
|
||||
return _getValue(values, colMap, keys);
|
||||
}
|
||||
|
||||
String? trackName = getVal(['track name', 'track', 'name', 'title']);
|
||||
String? artistName = getVal(['artist name', 'artist']);
|
||||
String? albumName = getVal(['album name', 'album']);
|
||||
String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes
|
||||
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing
|
||||
|
||||
// If 'spotify uri' contains the id: 'spotify:track:ID'
|
||||
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
|
||||
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
||||
}
|
||||
|
||||
// Basic validation: Need at least name and artist, OR a spotify ID
|
||||
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
|
||||
tracks.add(Track(
|
||||
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||
name: trackName ?? 'Unknown Track',
|
||||
artistName: artistName ?? 'Unknown Artist',
|
||||
albumName: albumName ?? 'Unknown Album',
|
||||
isrc: isrc,
|
||||
duration: 0, // Will be updated by enrichment later
|
||||
coverUrl: null, // Will be fetched by enrichment
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Parsed ${tracks.length} tracks from CSV');
|
||||
return tracks;
|
||||
}
|
||||
|
||||
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
|
||||
for (final key in possibleKeys) {
|
||||
if (colMap.containsKey(key)) {
|
||||
final index = colMap[key]!;
|
||||
if (index < values.length) {
|
||||
return _cleanValue(values[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _cleanValue(String val) {
|
||||
val = val.trim();
|
||||
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
||||
val = val.substring(1, val.length - 1);
|
||||
}
|
||||
// Handle double quotes escape in CSV ("" -> ")
|
||||
val = val.replaceAll('""', '"');
|
||||
return val;
|
||||
}
|
||||
|
||||
// Robust CSV Line Parser
|
||||
static List<String> _parseLine(String line) {
|
||||
final List<String> result = [];
|
||||
bool inQuote = false;
|
||||
StringBuffer buffer = StringBuffer();
|
||||
|
||||
for (int i=0; i<line.length; i++) {
|
||||
String char = line[i];
|
||||
if (char == '"') {
|
||||
// Look ahead to check for escaped quote
|
||||
if (i + 1 < line.length && line[i+1] == '"') {
|
||||
buffer.write('"'); // Keep format for now, _cleanValue handles unescaping logic differently...
|
||||
// Wait, standard CSV: "Thumb ""Up""" -> Thumb "Up"
|
||||
// My _cleanValue handles it, so I should just preserve raw content here mostly,
|
||||
// BUT I need to know if " toggles inQuote.
|
||||
// Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote).
|
||||
buffer.write('"'); // Write 1st quote
|
||||
i++; // Skip next quote char loop
|
||||
buffer.write('"'); // Write 2nd quote
|
||||
} else {
|
||||
inQuote = !inQuote;
|
||||
buffer.write(char);
|
||||
}
|
||||
} else if (char == ',' && !inQuote) {
|
||||
result.add(buffer.toString());
|
||||
buffer.clear();
|
||||
} else {
|
||||
buffer.write(char);
|
||||
}
|
||||
}
|
||||
result.add(buffer.toString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user