refactor: additional code cleanup

This commit is contained in:
zarzet
2026-01-17 09:36:05 +07:00
parent b96233f90b
commit 621582cf11
16 changed files with 27 additions and 253 deletions
+3 -28
View File
@@ -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
View File
@@ -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]
+3 -95
View File
@@ -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();
-2
View File
@@ -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;
}
-9
View File
@@ -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,
+5 -45
View File
@@ -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,
-4
View File
@@ -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),
-4
View File
@@ -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')) {
-3
View File
@@ -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(
+1 -1
View File
@@ -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(
-2
View File
@@ -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}\]');
+7 -17
View File
@@ -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 {
-5
View File
@@ -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;
-1
View File
@@ -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();
}
}