mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-21 23:47:04 +02:00
refactor: additional code cleanup
This commit is contained in:
+3
-28
@@ -33,7 +33,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
// Find or create vorbis comment block
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
@@ -123,7 +122,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Save file
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
@@ -403,7 +401,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read first 4 bytes to detect file type
|
||||
marker := make([]byte, 4)
|
||||
if _, err := file.Read(marker); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||
@@ -429,13 +426,10 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||
}
|
||||
|
||||
// Parse sample rate (20 bits starting at byte 10)
|
||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||
|
||||
// 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 |
|
||||
@@ -449,17 +443,14 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
}, 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
|
||||
file.Seek(0, 0)
|
||||
header8 := make([]byte, 8)
|
||||
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
|
||||
file.Close()
|
||||
return GetM4AQuality(filePath)
|
||||
}
|
||||
|
||||
@@ -471,9 +462,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
// ========================================
|
||||
|
||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||
// This is a simplified implementation that writes metadata to the file
|
||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||
// Read the entire file
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||
@@ -485,11 +474,9 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
return fmt.Errorf("moov atom not found in M4A file")
|
||||
}
|
||||
|
||||
// Find udta atom inside moov, or create one
|
||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||
|
||||
// Build new metadata atoms
|
||||
metaAtom := buildMetaAtom(metadata, coverData)
|
||||
|
||||
var newData []byte
|
||||
@@ -499,13 +486,11 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||
|
||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||
// Replace existing meta atom
|
||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
||||
newData = append(newData, data[:metaPos]...)
|
||||
newData = append(newData, metaAtom...)
|
||||
newData = append(newData, data[metaPos+metaSize:]...)
|
||||
} else {
|
||||
// Add meta atom to udta
|
||||
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||
newUdtaSize := 8 + len(newUdtaContent)
|
||||
newUdta := make([]byte, 4)
|
||||
@@ -521,7 +506,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||
}
|
||||
} else {
|
||||
// Create new udta with meta
|
||||
udtaContent := metaAtom
|
||||
udtaSize := 8 + len(udtaContent)
|
||||
newUdta := make([]byte, 4)
|
||||
@@ -532,7 +516,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
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...)
|
||||
@@ -546,7 +529,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||
newData[moovPos+3] = byte(newMoovSize)
|
||||
|
||||
// Write back to file
|
||||
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||
}
|
||||
@@ -573,7 +555,6 @@ func findAtom(data []byte, name string, offset int) int {
|
||||
|
||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
// Build ilst content
|
||||
var ilst []byte
|
||||
|
||||
// ©nam - Title
|
||||
@@ -631,7 +612,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||
ilstAtom = append(ilstAtom, ilst...)
|
||||
|
||||
// Build hdlr atom (required for meta)
|
||||
hdlr := []byte{
|
||||
0, 0, 0, 33, // size = 33
|
||||
'h', 'd', 'l', 'r',
|
||||
@@ -788,18 +768,13 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||
}
|
||||
|
||||
// Search for mp4a or alac atom which contains audio info
|
||||
// This is a simplified search - real implementation would traverse the atom tree
|
||||
for i := moovPos; i < len(data)-20; i++ {
|
||||
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
||||
if i+24 < len(data) {
|
||||
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||
// For AAC, bit depth is typically 16
|
||||
bitDepth := 16
|
||||
if string(data[i:i+4]) == "alac" {
|
||||
// ALAC can have higher bit depth, check esds or alac specific data
|
||||
bitDepth = 24 // Assume 24-bit for ALAC
|
||||
bitDepth = 24
|
||||
}
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
}
|
||||
|
||||
+8
-32
@@ -130,16 +130,14 @@ func NewTidalDownloader() *TidalDownloader {
|
||||
// GetAvailableAPIs returns list of available Tidal APIs
|
||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||
encodedAPIs := []string{
|
||||
// Priority 1: APIs that return FULL tracks (not PREVIEW)
|
||||
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - returns FULL
|
||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||
// Priority 2: qqdl.site APIs (often return PREVIEW only)
|
||||
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
||||
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
||||
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
||||
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
||||
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
||||
"dGlkYWwua2lub3BsdXMub25saW5l",
|
||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
|
||||
"dHJpdG9uLnNxdWlkLnd0Zg==",
|
||||
"dm9nZWwucXFkbC5zaXRl",
|
||||
"bWF1cy5xcWRsLnNpdGU=",
|
||||
"aHVuZC5xcWRsLnNpdGU=",
|
||||
"a2F0emUucXFkbC5zaXRl",
|
||||
"d29sZi5xcWRsLnNpdGU=",
|
||||
}
|
||||
|
||||
var apis []string
|
||||
@@ -159,7 +157,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
t.tokenMu.Lock()
|
||||
defer t.tokenMu.Unlock()
|
||||
|
||||
// Return cached token if still valid (with 60s buffer)
|
||||
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
|
||||
return t.cachedToken, nil
|
||||
}
|
||||
@@ -385,22 +382,17 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
queries = append(queries, artistName+" "+trackName)
|
||||
}
|
||||
|
||||
// Strategy 2: Track name only
|
||||
if trackName != "" {
|
||||
queries = append(queries, trackName)
|
||||
}
|
||||
|
||||
// Strategy 3: Romaji versions if Japanese detected (NEW - from PC version)
|
||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||
// Convert to romaji (hiragana/katakana only, kanji stays)
|
||||
romajiTrack := JapaneseToRomaji(trackName)
|
||||
romajiArtist := JapaneseToRomaji(artistName)
|
||||
|
||||
// Clean and remove ALL non-ASCII characters (including kanji)
|
||||
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||
|
||||
// Artist + Track romaji (cleaned to ASCII only)
|
||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||
if !containsQuery(queries, romajiQuery) {
|
||||
@@ -409,14 +401,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
}
|
||||
|
||||
// Track romaji only (cleaned)
|
||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||
if !containsQuery(queries, cleanRomajiTrack) {
|
||||
queries = append(queries, cleanRomajiTrack)
|
||||
}
|
||||
}
|
||||
|
||||
// Also try with partial romaji (artist + cleaned track)
|
||||
if artistName != "" && cleanRomajiTrack != "" {
|
||||
partialQuery := artistName + " " + cleanRomajiTrack
|
||||
if !containsQuery(queries, partialQuery) {
|
||||
@@ -425,7 +415,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Artist only as last resort
|
||||
if artistName != "" {
|
||||
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
||||
@@ -435,7 +424,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
|
||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||
|
||||
// Collect all search results from all queries
|
||||
var allTracks []TidalTrack
|
||||
searchedQueries := make(map[string]bool)
|
||||
|
||||
@@ -485,7 +473,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
for i := range result.Items {
|
||||
if result.Items[i].ISRC == spotifyISRC {
|
||||
track := &result.Items[i]
|
||||
// Verify duration if provided
|
||||
if expectedDuration > 0 {
|
||||
durationDiff := track.Duration - expectedDuration
|
||||
if durationDiff < 0 {
|
||||
@@ -495,7 +482,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||
return track, nil
|
||||
}
|
||||
// Duration mismatch, continue searching
|
||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||
expectedDuration, track.Duration)
|
||||
} else {
|
||||
@@ -514,7 +500,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
return nil, fmt.Errorf("no tracks found for any search query")
|
||||
}
|
||||
|
||||
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||
if spotifyISRC != "" {
|
||||
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||
var isrcMatches []*TidalTrack
|
||||
@@ -526,7 +511,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
|
||||
if len(isrcMatches) > 0 {
|
||||
// Verify duration first (most important check)
|
||||
if expectedDuration > 0 {
|
||||
var durationVerifiedMatches []*TidalTrack
|
||||
for _, track := range isrcMatches {
|
||||
@@ -534,37 +518,31 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
// Allow 3 seconds tolerance for duration (same as PC version)
|
||||
if durationDiff <= 3 {
|
||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(durationVerifiedMatches) > 0 {
|
||||
// Return first duration-verified match
|
||||
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
|
||||
// ISRC matches but duration doesn't - this is likely wrong version
|
||||
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||
expectedDuration, isrcMatches[0].Duration)
|
||||
}
|
||||
|
||||
// No duration to verify, just return first ISRC match
|
||||
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
return isrcMatches[0], nil
|
||||
}
|
||||
|
||||
// If ISRC was provided but no match found, return error
|
||||
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||
}
|
||||
|
||||
// Priority 2: Match by duration (within tolerance) + prefer best quality
|
||||
if expectedDuration > 0 {
|
||||
tolerance := 3 // 3 seconds tolerance
|
||||
var durationMatches []*TidalTrack
|
||||
@@ -581,7 +559,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
|
||||
if len(durationMatches) > 0 {
|
||||
// Find best quality among duration matches
|
||||
bestMatch := durationMatches[0]
|
||||
for _, track := range durationMatches {
|
||||
for _, tag := range track.MediaMetadata.Tags {
|
||||
@@ -597,7 +574,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Just take the best quality from first results
|
||||
bestMatch := &allTracks[0]
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
|
||||
@@ -26,7 +26,6 @@ String? _normalizeOptionalString(String? value) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Download History Item model
|
||||
class DownloadHistoryItem {
|
||||
final String id;
|
||||
final String trackName;
|
||||
@@ -37,7 +36,6 @@ class DownloadHistoryItem {
|
||||
final String filePath;
|
||||
final String service;
|
||||
final DateTime downloadedAt;
|
||||
// Additional metadata
|
||||
final String? isrc;
|
||||
final String? spotifyId;
|
||||
final int? trackNumber;
|
||||
@@ -113,7 +111,6 @@ class DownloadHistoryItem {
|
||||
);
|
||||
}
|
||||
|
||||
// Download History State
|
||||
class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||
@@ -133,7 +130,6 @@ class DownloadHistoryState {
|
||||
}
|
||||
}
|
||||
|
||||
// Download History Notifier (Riverpod 3.x)
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const _storageKey = 'download_history';
|
||||
bool _isLoaded = false;
|
||||
@@ -208,7 +204,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
||||
}
|
||||
} else {
|
||||
// No identifier - keep it (can't deduplicate)
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
@@ -240,7 +235,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match Deezer tracks: extract numeric ID from "deezer:123456" format
|
||||
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
|
||||
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
|
||||
final itemDeezerId = item.spotifyId!.substring(7);
|
||||
@@ -259,10 +253,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
});
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing entry (update with new download info)
|
||||
final updatedItems = [...state.items];
|
||||
updatedItems[existingIndex] = item;
|
||||
// Move to top of list (most recent)
|
||||
updatedItems.removeAt(existingIndex);
|
||||
updatedItems.insert(0, item);
|
||||
state = state.copyWith(items: updatedItems);
|
||||
@@ -301,7 +293,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Download History Provider
|
||||
final downloadHistoryProvider =
|
||||
NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
||||
DownloadHistoryNotifier.new,
|
||||
@@ -369,7 +360,6 @@ class DownloadQueueState {
|
||||
items.where((i) => i.status == DownloadStatus.downloading).length;
|
||||
}
|
||||
|
||||
// Download Queue Notifier (Riverpod 3.x)
|
||||
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Timer? _progressTimer;
|
||||
int _downloadCount = 0; // Counter for connection cleanup
|
||||
@@ -384,7 +374,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
// Cleanup timer when provider is disposed
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
@@ -411,7 +400,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Reset downloading items to queued (they were interrupted)
|
||||
final restoredItems = items.map((item) {
|
||||
if (item.status == DownloadStatus.downloading) {
|
||||
return item.copyWith(status: DownloadStatus.queued, progress: 0);
|
||||
@@ -527,10 +515,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (isDownloading) {
|
||||
double percentage = 0.0;
|
||||
if (bytesTotal > 0) {
|
||||
// Calculate from bytes if available for precision
|
||||
percentage = bytesReceived / bytesTotal;
|
||||
} else {
|
||||
// Fallback to backend-reported progress (e.g. for DASH segments)
|
||||
percentage = progressFromBackend;
|
||||
}
|
||||
|
||||
@@ -558,14 +544,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return; // Don't show download progress notification
|
||||
}
|
||||
|
||||
// Update notification with active downloads
|
||||
if (items.isNotEmpty) {
|
||||
final firstEntry = items.entries.first;
|
||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||
|
||||
// Find downloading items (not finalizing)
|
||||
final downloadingItems = state.items
|
||||
.where((i) => i.status == DownloadStatus.downloading)
|
||||
.toList();
|
||||
@@ -627,7 +611,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
} else {
|
||||
// Android: Use external storage Music folder
|
||||
final dir = await getExternalStorageDirectory();
|
||||
if (dir != null) {
|
||||
final musicDir = Directory(
|
||||
@@ -685,11 +668,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
switch (albumFolderStructure) {
|
||||
case 'album_only':
|
||||
// Albums/Album structure (no artist folder)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
case 'artist_year_album':
|
||||
// Albums/Artist/[Year] Album structure
|
||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
|
||||
break;
|
||||
@@ -710,7 +691,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Original folder organization logic (when separateSingles is disabled)
|
||||
if (folderOrganization == 'none') {
|
||||
return baseDir;
|
||||
}
|
||||
@@ -756,7 +736,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
/// Extract year from release date (format: "2005-06-13" or "2005")
|
||||
String? _extractYear(String? releaseDate) {
|
||||
if (releaseDate == null || releaseDate.isEmpty) return null;
|
||||
// Handle both "2005-06-13" and "2005" formats
|
||||
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
|
||||
return match?.group(1);
|
||||
}
|
||||
@@ -774,7 +753,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
String addToQueue(Track track, String service, {String? qualityOverride}) {
|
||||
// Sync settings before adding to queue
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
|
||||
@@ -789,10 +767,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
state = state.copyWith(items: [...state.items, item]);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
_saveQueueToStorage();
|
||||
|
||||
if (!state.isProcessing) {
|
||||
// Run in microtask to not block UI
|
||||
Future.microtask(() => _processQueue());
|
||||
}
|
||||
|
||||
@@ -804,7 +781,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String service, {
|
||||
String? qualityOverride,
|
||||
}) {
|
||||
// Sync settings before adding to queue
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
|
||||
@@ -824,7 +800,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
if (!state.isProcessing) {
|
||||
// Run in microtask to not block UI
|
||||
Future.microtask(() => _processQueue());
|
||||
}
|
||||
}
|
||||
@@ -854,7 +829,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
|
||||
// Persist queue when status changes to completed/failed/skipped (item removed from pending)
|
||||
if (status == DownloadStatus.completed ||
|
||||
status == DownloadStatus.failed ||
|
||||
status == DownloadStatus.skipped) {
|
||||
@@ -940,7 +914,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only retry if status is failed or skipped
|
||||
if (item.status != DownloadStatus.failed &&
|
||||
item.status != DownloadStatus.skipped) {
|
||||
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
||||
@@ -983,7 +956,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
|
||||
// Check if post-processing is enabled and there are extensions with hooks
|
||||
if (!settings.useExtensionProviders) return;
|
||||
|
||||
final hasPostProcessing = extensionState.extensions.any(
|
||||
@@ -993,7 +965,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Running post-processing hooks on: $filePath');
|
||||
|
||||
// Build metadata map for post-processing
|
||||
final metadata = <String, dynamic>{
|
||||
'title': track.name,
|
||||
'artist': track.artistName,
|
||||
@@ -1023,7 +994,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Post-processing error: $e');
|
||||
// Don't fail the download if post-processing fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,15 +1002,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
||||
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
||||
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
|
||||
const spotifySizeMax = 'ab67616d000082c1';
|
||||
|
||||
// First upgrade small (300) to medium (640)
|
||||
var result = coverUrl;
|
||||
if (result.contains(spotifySize300)) {
|
||||
result = result.replaceFirst(spotifySize300, spotifySize640);
|
||||
}
|
||||
|
||||
// Then upgrade medium (640) to max
|
||||
if (result.contains(spotifySize640)) {
|
||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||
}
|
||||
@@ -1052,7 +1020,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// Download cover first
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
@@ -1119,9 +1086,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Metadata map content: $metadata');
|
||||
|
||||
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
||||
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
||||
// This ensures even converted files have lyrics embedded if available
|
||||
try {
|
||||
final lrcContent = await PlatformBridge.getLyricsLRC(
|
||||
track.id, // spotifyID
|
||||
@@ -1141,8 +1105,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Generating tags for FLAC: $metadata');
|
||||
|
||||
// Perform embedding (cover + text metadata)
|
||||
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
||||
final result = await FFmpegService.embedMetadata(
|
||||
flacPath: flacPath,
|
||||
coverPath: coverPath != null && await File(coverPath).exists()
|
||||
@@ -1157,14 +1119,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.w('FFmpeg metadata/cover embed failed');
|
||||
}
|
||||
|
||||
// Clean up cover file if it exists
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final coverFile = File(coverPath);
|
||||
if (await coverFile.exists()) {
|
||||
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
||||
// in this session or if it's not in our app dir.
|
||||
// But coverPath is typically in temp dir now.
|
||||
await coverFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -1180,14 +1138,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(isProcessing: true);
|
||||
_log.i('Starting queue processing...');
|
||||
|
||||
// Track total items at start for notification
|
||||
_totalQueuedAtStart = state.items
|
||||
.where((i) => i.status == DownloadStatus.queued)
|
||||
.length;
|
||||
_completedInSession = 0;
|
||||
_failedInSession = 0;
|
||||
|
||||
// Start foreground service to keep downloads running in background (Android only)
|
||||
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||
final firstItem = state.items.firstWhere(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
@@ -1205,13 +1161,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure output directory is initialized before processing
|
||||
if (state.outputDir.isEmpty) {
|
||||
_log.d('Output dir empty, initializing...');
|
||||
await _initOutputDir();
|
||||
}
|
||||
|
||||
// If still empty, use fallback
|
||||
if (state.outputDir.isEmpty) {
|
||||
_log.d('Using fallback directory...');
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
@@ -1225,7 +1179,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Output directory: ${state.outputDir}');
|
||||
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||
|
||||
// Use parallel processing if concurrentDownloads > 1
|
||||
if (state.concurrentDownloads > 1) {
|
||||
await _processQueueParallel();
|
||||
} else {
|
||||
@@ -1234,7 +1187,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_stopProgressPolling();
|
||||
|
||||
// Stop foreground service (Android only)
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await PlatformBridge.stopDownloadService();
|
||||
@@ -1244,7 +1196,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Final cleanup after queue finishes
|
||||
if (_downloadCount > 0) {
|
||||
_log.d('Final connection cleanup...');
|
||||
try {
|
||||
@@ -1255,7 +1206,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_downloadCount = 0;
|
||||
}
|
||||
|
||||
// Show queue completion notification
|
||||
_log.i(
|
||||
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||
);
|
||||
@@ -1269,7 +1219,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.i('Queue processing finished');
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
|
||||
// Check if there are new queued items (e.g., from retry) and restart if needed
|
||||
final hasQueuedItems = state.items.any(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
);
|
||||
@@ -1283,18 +1232,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
/// Sequential download processing (uses multi-progress system with single item)
|
||||
Future<void> _processQueueSequential() async {
|
||||
// Start multi-progress polling (works for both sequential and parallel)
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
// Check if paused
|
||||
if (state.isPaused) {
|
||||
_log.d('Queue is paused, waiting...');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-read state to get latest items (important for retry)
|
||||
final currentItems = state.items;
|
||||
final nextItem = currentItems.firstWhere(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
@@ -1324,11 +1270,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
await _downloadSingleItem(nextItem);
|
||||
|
||||
// Clear item progress after download completes
|
||||
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
|
||||
}
|
||||
|
||||
// Stop polling when queue is done
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
@@ -1337,11 +1281,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final maxConcurrent = state.concurrentDownloads;
|
||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||
|
||||
// Start multi-progress polling (shared with sequential mode)
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
// Check if paused - don't start new downloads but let active ones finish
|
||||
if (state.isPaused) {
|
||||
_log.d('Queue is paused, waiting for active downloads...');
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
@@ -1352,7 +1294,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get queued items
|
||||
final queuedItems = state.items
|
||||
.where((item) => item.status == DownloadStatus.queued)
|
||||
.toList();
|
||||
@@ -1362,19 +1303,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
break;
|
||||
}
|
||||
|
||||
// Start new downloads up to max concurrent limit
|
||||
while (activeDownloads.length < maxConcurrent &&
|
||||
queuedItems.isNotEmpty &&
|
||||
!state.isPaused) {
|
||||
final item = queuedItems.removeAt(0);
|
||||
|
||||
// Mark as downloading immediately to prevent double-processing
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
|
||||
// Create the download future
|
||||
final future = _downloadSingleItem(item).whenComplete(() {
|
||||
activeDownloads.remove(item.id);
|
||||
// Clear item progress after download completes
|
||||
PlatformBridge.clearItemProgress(item.id).catchError((_) {});
|
||||
});
|
||||
|
||||
@@ -1384,18 +1321,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for at least one download to complete before checking for more
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
await Future.any(activeDownloads.values);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all remaining downloads to complete
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
await Future.wait(activeDownloads.values);
|
||||
}
|
||||
|
||||
// Stop polling when queue is done
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
@@ -1419,15 +1353,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
|
||||
try {
|
||||
// Get folder organization setting and build output directory
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// Metadata Enrichment:
|
||||
// If track number is missing/0 (common from Search results), fetch full metadata
|
||||
// This ensures the downloaded file has correct tags (Track, Disc, Year)
|
||||
Track trackToDownload = item.track;
|
||||
// Enrich metadata if ISRC or track number is missing (common from Search results)
|
||||
// ISRC is critical for accurate track matching on streaming services
|
||||
final needsEnrichment =
|
||||
trackToDownload.id.startsWith('deezer:') &&
|
||||
(trackToDownload.isrc == null ||
|
||||
@@ -1452,7 +1380,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Got response keys: ${fullData.keys.toList()}');
|
||||
|
||||
if (fullData.containsKey('track')) {
|
||||
// Parse Go backend response (snake_case) to Track
|
||||
final trackData = fullData['track'];
|
||||
_log.d('Track data type: ${trackData.runtimeType}');
|
||||
if (trackData is Map<String, dynamic>) {
|
||||
@@ -1500,7 +1427,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Log cover URL for debugging CSV import issues
|
||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||
|
||||
final normalizedAlbumArtist =
|
||||
@@ -1518,7 +1444,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
Map<String, dynamic> result;
|
||||
|
||||
// Check if extension providers should be used
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled);
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveExtensions;
|
||||
@@ -1597,7 +1522,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Result: $result');
|
||||
|
||||
// Check if item was cancelled while downloading
|
||||
final currentItem = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
@@ -1623,14 +1547,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (result['success'] == true) {
|
||||
var filePath = result['file_path'] as String?;
|
||||
|
||||
// Strip EXISTS: prefix from duplicate detection
|
||||
if (filePath != null && filePath.startsWith('EXISTS:')) {
|
||||
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
|
||||
}
|
||||
|
||||
_log.i('Download success, file: $filePath');
|
||||
|
||||
// Get actual quality from response (if available)
|
||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||
String actualQuality = quality; // Default to requested quality
|
||||
@@ -1646,7 +1568,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.i('Actual quality: $actualQuality');
|
||||
}
|
||||
|
||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||
_log.d(
|
||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||
@@ -1676,11 +1597,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
filePath = flacPath;
|
||||
_log.d('Converted to FLAC: $flacPath');
|
||||
|
||||
// After conversion, embed metadata and cover to the new FLAC file
|
||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||
try {
|
||||
// Update track with actual metadata from backend result (if available)
|
||||
// This creates the most accurate metadata possible (from the service itself)
|
||||
Track finalTrack = trackToDownload;
|
||||
if (result.containsKey('track_number') ||
|
||||
result.containsKey('release_date')) {
|
||||
@@ -1742,18 +1660,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||
// Keep the M4A file if conversion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Check again if cancelled before updating status and adding to history
|
||||
final itemAfterDownload = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled during finalization, cleaning up');
|
||||
// Delete the downloaded file
|
||||
if (filePath != null) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
@@ -1775,15 +1690,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
filePath: filePath,
|
||||
);
|
||||
|
||||
// Run post-processing hooks if enabled
|
||||
if (filePath != null) {
|
||||
await _runPostProcessingHooks(filePath, trackToDownload);
|
||||
}
|
||||
|
||||
// Increment completed counter
|
||||
_completedInSession++;
|
||||
|
||||
// Show completion notification for this track
|
||||
|
||||
await _notificationService.showDownloadComplete(
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
@@ -1792,7 +1704,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (filePath != null) {
|
||||
// Extract metadata from backend result (most accurate source)
|
||||
final backendTitle = result['title'] as String?;
|
||||
final backendArtist = result['artist'] as String?;
|
||||
final backendAlbum = result['album'] as String?;
|
||||
@@ -1851,7 +1762,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
),
|
||||
);
|
||||
|
||||
// Auto-remove completed item from queue (it's now in history)
|
||||
removeItem(item.id);
|
||||
}
|
||||
} else {
|
||||
@@ -1901,7 +1811,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_failedInSession++;
|
||||
}
|
||||
|
||||
// Increment download counter and cleanup connections periodically
|
||||
_downloadCount++;
|
||||
if (_downloadCount % _cleanupInterval == 0) {
|
||||
_log.d(
|
||||
@@ -1928,7 +1837,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String errorMsg = e.toString();
|
||||
DownloadErrorType errorType = DownloadErrorType.unknown;
|
||||
|
||||
// Check for specific Deezer fallback error
|
||||
if (errorMsg.contains('could not find Deezer equivalent') ||
|
||||
errorMsg.contains('track not found on Deezer')) {
|
||||
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||
|
||||
@@ -200,11 +200,9 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
|
||||
void _recordAccess(RecentAccessItem item) {
|
||||
// Debug log
|
||||
// ignore: avoid_print
|
||||
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
|
||||
|
||||
// Remove any existing entry with same unique key
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
.toList();
|
||||
|
||||
@@ -53,7 +53,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
/// Apply current Spotify credentials to Go backend
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
// Only apply if both fields are set
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
@@ -197,7 +196,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
// Sync logging state to LogBuffer
|
||||
LogBuffer.loggingEnabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
playlistName: owner?['name'] as String?,
|
||||
coverUrl: owner?['images'] as String?,
|
||||
);
|
||||
// Pre-warm cache for playlist tracks in background
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
@@ -279,7 +278,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? metadataSource}) async {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve hasSearchText during search
|
||||
@@ -345,10 +343,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||
|
||||
// Parse tracks with error handling per item
|
||||
final tracks = <Track>[];
|
||||
|
||||
// Add extension tracks first (they have priority)
|
||||
tracks.addAll(extensionTracks);
|
||||
|
||||
final existingIsrcs = extensionTracks
|
||||
@@ -404,7 +400,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
/// Perform custom search using a specific extension
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve hasSearchText during search
|
||||
@@ -484,7 +479,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (e) {
|
||||
// Silently fail availability check
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +530,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||
// Handle duration_ms which might be int or double
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
@@ -591,11 +584,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
/// Pre-warm track ID cache for faster downloads
|
||||
/// Runs in background, doesn't block UI
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
// Only pre-warm if we have tracks with ISRC
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
// Build request list for Go backend
|
||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||
'isrc': t.isrc!,
|
||||
'track_name': t.name,
|
||||
|
||||
@@ -296,7 +296,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
switch (filterMode) {
|
||||
case 'albums':
|
||||
// Album = more than 1 track from same album in history
|
||||
return items.where((item) {
|
||||
final key =
|
||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
@@ -379,7 +378,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
albumKeys.add(key);
|
||||
}
|
||||
|
||||
// Count albums with more than 1 track
|
||||
int count = 0;
|
||||
for (final key in albumKeys) {
|
||||
final trackCount = items
|
||||
@@ -412,7 +410,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Initialize page controller on first build
|
||||
_initializePageController();
|
||||
|
||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
@@ -490,7 +487,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// Pause/Resume controls
|
||||
if ((isProcessing || queuedCount > 0) &&
|
||||
(queueItems.length > 1 || isPaused))
|
||||
SliverToBoxAdapter(
|
||||
@@ -536,10 +532,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Queue header
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -550,10 +545,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Queue list
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
@@ -618,7 +612,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (notification is OverscrollNotification) {
|
||||
final overscroll = notification.overscroll;
|
||||
|
||||
// At first page and overscrolling to the left -> push parent toward Home
|
||||
if (page == 0 && overscroll < 0) {
|
||||
final currentOffset = parentController.offset;
|
||||
final targetOffset = (currentOffset + overscroll).clamp(
|
||||
@@ -629,7 +622,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// At last page and overscrolling to the right -> push parent toward next tab
|
||||
if (page == 2 && overscroll > 0) {
|
||||
final currentOffset = parentController.offset;
|
||||
final targetOffset = (currentOffset + overscroll).clamp(
|
||||
@@ -641,32 +633,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// Snap parent to nearest page when scroll ends
|
||||
if (notification is ScrollEndNotification) {
|
||||
if (page == 0 || page == 2) {
|
||||
final currentPage = parentController.page ?? widget.parentPageIndex.toDouble();
|
||||
final historyPage = widget.parentPageIndex.toDouble();
|
||||
final offset = currentPage - historyPage;
|
||||
|
||||
// Only snap if we've moved the parent
|
||||
if (offset.abs() > 0.01) {
|
||||
// Use 0.3 threshold (30%)
|
||||
if (offset < -0.3) {
|
||||
// Swiped enough toward Home - animate to Home
|
||||
parentController.animateToPage(
|
||||
widget.parentPageIndex - 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} else if (offset > 0.3) {
|
||||
// Swiped enough toward next tab - animate to next
|
||||
parentController.animateToPage(
|
||||
widget.nextPageIndex ?? (widget.parentPageIndex + 1),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} else {
|
||||
// Not enough - instant jump back (no animation)
|
||||
parentController.jumpToPage(widget.parentPageIndex);
|
||||
}
|
||||
}
|
||||
@@ -680,7 +666,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
physics: const ClampingScrollPhysics(),
|
||||
onPageChanged: _onFilterPageChanged,
|
||||
children: [
|
||||
// All tab
|
||||
_buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
@@ -690,7 +675,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
),
|
||||
// Albums tab
|
||||
_buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
@@ -700,7 +684,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
),
|
||||
// Singles tab
|
||||
_buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
@@ -715,7 +698,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom Selection Action Bar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
@@ -748,7 +730,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// History section header
|
||||
if (historyItems.isNotEmpty &&
|
||||
queueItems.isEmpty &&
|
||||
filterMode != 'albums')
|
||||
@@ -779,7 +760,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// Albums section header (when Albums filter is selected)
|
||||
if (groupedAlbums.isNotEmpty &&
|
||||
queueItems.isEmpty &&
|
||||
filterMode == 'albums')
|
||||
@@ -795,7 +775,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// History section header when queue has items
|
||||
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -831,7 +810,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// History - Grid or List (for All and Singles filter)
|
||||
if (historyItems.isNotEmpty && filterMode != 'albums')
|
||||
historyViewMode == 'grid'
|
||||
? SliverPadding(
|
||||
@@ -871,10 +849,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
}, childCount: historyItems.length),
|
||||
),
|
||||
}, childCount: historyItems.length ),
|
||||
),
|
||||
|
||||
// Empty state
|
||||
if (queueItems.isEmpty &&
|
||||
historyItems.isEmpty &&
|
||||
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
||||
@@ -887,7 +864,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
)
|
||||
else
|
||||
// Add bottom padding when selection mode is active to avoid overlap with bottom bar
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
||||
),
|
||||
@@ -956,7 +932,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Album cover with track count badge
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -982,7 +957,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Track count badge
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
@@ -1020,16 +994,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album name
|
||||
Text(
|
||||
album.albumName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600 ),
|
||||
),
|
||||
// Artist name
|
||||
Text(
|
||||
album.artistName,
|
||||
maxLines: 1,
|
||||
@@ -1084,10 +1056,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// Selection info row
|
||||
Row(
|
||||
children: [
|
||||
// Close button
|
||||
IconButton.filledTonal(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
@@ -1141,7 +1111,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Delete button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
@@ -1449,7 +1418,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Quality badge
|
||||
if (item.quality != null && item.quality!.contains('bit'))
|
||||
Positioned(
|
||||
left: 4,
|
||||
@@ -1478,7 +1446,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Play button
|
||||
if (fileExists && !_isSelectionMode)
|
||||
Positioned(
|
||||
right: 4,
|
||||
@@ -1499,7 +1466,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Error indicator
|
||||
if (!fileExists && !_isSelectionMode)
|
||||
Positioned(
|
||||
right: 4,
|
||||
@@ -1517,7 +1483,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Selection overlay
|
||||
if (_isSelectionMode)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
@@ -1550,7 +1515,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Selection checkbox
|
||||
if (_isSelectionMode)
|
||||
Positioned(
|
||||
right: 4,
|
||||
@@ -1618,7 +1582,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Selection checkbox
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
@@ -1645,7 +1608,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
// Cover art
|
||||
item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -1672,7 +1634,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1740,7 +1701,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Action buttons (hide in selection mode)
|
||||
if (!_isSelectionMode)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -51,7 +51,6 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// App header card with logo and description
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
@@ -208,7 +207,6 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
@@ -240,8 +238,6 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// App logo
|
||||
// App logo
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
|
||||
@@ -38,7 +38,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Preview Section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -211,7 +210,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Fake Album Art
|
||||
Container(
|
||||
width: 108,
|
||||
height: 108,
|
||||
@@ -627,7 +625,6 @@ class _ViewModeChip extends StatelessWidget {
|
||||
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),
|
||||
|
||||
@@ -211,7 +211,6 @@ class _LogScreenState extends State<LogScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
// Level filter
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
@@ -309,7 +308,6 @@ class _LogScreenState extends State<LogScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// Log entries section
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
|
||||
@@ -628,7 +626,6 @@ class _LogSummaryCard extends StatelessWidget {
|
||||
final errorLower = (log.error ?? '').toLowerCase();
|
||||
final combined = '$msgLower $errorLower';
|
||||
|
||||
// Check for ISP blocking (detected by Go backend)
|
||||
if (combined.contains('isp blocking') ||
|
||||
combined.contains('isp may be') ||
|
||||
combined.contains('blocked by isp') ||
|
||||
@@ -642,7 +639,6 @@ class _LogSummaryCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rate limiting
|
||||
if (combined.contains('rate limit') ||
|
||||
combined.contains('429') ||
|
||||
combined.contains('too many requests')) {
|
||||
|
||||
@@ -76,7 +76,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
||||
storageGranted = manageStatus.isGranted;
|
||||
} else {
|
||||
// Android 10 and below: Use legacy storage permission
|
||||
final storageStatus = await Permission.storage.status;
|
||||
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
||||
storageGranted = storageStatus.isGranted;
|
||||
@@ -183,7 +182,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
allGranted = manageStatus.isGranted;
|
||||
|
||||
} else {
|
||||
// Android 10 and below: Use legacy storage permission
|
||||
final status = await Permission.storage.request();
|
||||
allGranted = status.isGranted;
|
||||
|
||||
@@ -920,7 +918,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Info banner
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -508,7 +508,7 @@ class _ExtensionItem extends StatelessWidget {
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w500 ),
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
|
||||
@@ -290,7 +290,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
],
|
||||
),
|
||||
|
||||
// File status
|
||||
if (!fileExists) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
@@ -806,7 +805,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
String _cleanLrcForDisplay(String lrc) {
|
||||
// Remove LRC timestamps [mm:ss.xx] for cleaner display
|
||||
final lines = lrc.split('\n');
|
||||
final cleanLines = <String>[];
|
||||
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
||||
|
||||
@@ -50,7 +50,6 @@ class CsvImportService {
|
||||
if (track.coverUrl == null || track.duration == 0) {
|
||||
Map<String, dynamic>? trackData;
|
||||
|
||||
// Try ISRC first if available
|
||||
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||
try {
|
||||
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
||||
@@ -112,7 +111,6 @@ class CsvImportService {
|
||||
|
||||
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
if (i < tracks.length - 1) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
@@ -147,7 +145,6 @@ class CsvImportService {
|
||||
|
||||
_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;
|
||||
@@ -161,10 +158,9 @@ class CsvImportService {
|
||||
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
|
||||
String? isrc = getVal(['isrc']);
|
||||
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']);
|
||||
|
||||
// If 'spotify uri' contains the id: 'spotify:track:ID'
|
||||
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
|
||||
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
||||
}
|
||||
@@ -207,23 +203,17 @@ class CsvImportService {
|
||||
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
|
||||
String char = line[i];
|
||||
if (char == '"') {
|
||||
if (i + 1 < line.length && line[i+1] == '"') {
|
||||
buffer.write('"');
|
||||
buffer.write('"');
|
||||
i++; // Skip next quote char loop
|
||||
buffer.write('"'); // Write 2nd quote
|
||||
} else {
|
||||
|
||||
@@ -57,7 +57,6 @@ class FFmpegService {
|
||||
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||
|
||||
// Create output directory
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
|
||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||
@@ -175,18 +174,14 @@ class FFmpegService {
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
// Copy temp output back to original location (replace)
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(flacPath);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
// Delete original file
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
// Copy temp file to original location
|
||||
await tempFile.copy(flacPath);
|
||||
// Delete temp file
|
||||
await tempFile.delete();
|
||||
|
||||
return flacPath;
|
||||
|
||||
@@ -38,7 +38,6 @@ class ShareIntentService {
|
||||
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
|
||||
if (initialMedia.isNotEmpty) {
|
||||
_handleSharedMedia(initialMedia, isInitial: true);
|
||||
// Tell the library that we are done processing the intent
|
||||
ReceiveSharingIntent.instance.reset();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user