refactor: code cleanup and improvements

This commit is contained in:
zarzet
2026-01-17 09:07:29 +07:00
parent be9444c76b
commit b96233f90b
32 changed files with 13 additions and 453 deletions
+1
View File
@@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
-12
View File
@@ -92,14 +92,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if coverPath != "" {
if fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
} else {
// Remove existing picture blocks first (like PC version)
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -137,7 +135,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find or create vorbis comment block
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -196,9 +193,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if len(coverData) > 0 {
// Remove existing picture blocks first
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -220,7 +215,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
}
}
// Save file
return f.Save(filePath)
}
@@ -257,7 +251,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
@@ -269,7 +262,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
// Also try DISC variant
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
@@ -277,7 +269,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
}
}
// Try DATE variants
if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR")
}
@@ -293,7 +284,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
return
}
// Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- {
comment := cmt.Comments[i]
@@ -305,7 +295,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
}
}
}
// Add new
cmt.Comments = append(cmt.Comments, key+"="+value)
}
@@ -313,7 +302,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments {
if len(comment) > len(key) {
// Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
-39
View File
@@ -194,7 +194,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return "", err
}
// Cache the token
t.cachedToken = result.AccessToken
if result.ExpiresIn > 0 {
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
@@ -662,12 +661,10 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
resultChan := make(chan tidalAPIResult, len(apis))
startTime := time.Now()
// Start all requests in parallel
for _, apiURL := range apis {
go func(api string) {
reqStart := time.Now()
// Create client with timeout for parallel requests
client := &http.Client{
Timeout: 15 * time.Second,
}
@@ -698,7 +695,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return
}
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
@@ -716,7 +712,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return
}
// Fallback to v1 format (array with OriginalTrackUrl)
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
@@ -738,13 +733,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
}(apiURL)
}
// Collect results - return first success
var errors []string
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil {
// First success - use this one
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
@@ -777,7 +770,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
}
// Use parallel approach - request from all APIs simultaneously
_, info, err := getDownloadURLParallel(apis, trackID, quality)
if err != nil {
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -795,16 +787,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
manifestStr := string(manifestBytes)
// Debug: log first 500 chars of manifest for debugging
manifestPreview := manifestStr
if len(manifestPreview) > 500 {
manifestPreview = manifestPreview[:500] + "..."
}
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
// Check if it's BTS format (JSON) or DASH format (XML)
if strings.HasPrefix(manifestStr, "{") {
// BTS format - JSON with direct URLs
var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
@@ -817,7 +806,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return btsManifest.URLs[0], "", nil, nil
}
// DASH format - XML with segments
var mpd MPD
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
@@ -828,7 +816,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaTemplate := segTemplate.Media
if initURL == "" || mediaTemplate == "" {
// Fallback: try regex extraction
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
@@ -844,11 +831,9 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
}
// Unescape HTML entities in URLs
initURL = strings.ReplaceAll(initURL, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
// Calculate segment count from timeline
segmentCount := 0
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
for i, seg := range segTemplate.Timeline.Segments {
@@ -857,10 +842,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
// If no segments found via XML, try regex
if segmentCount == 0 {
fmt.Println("[Tidal] No segments from XML, trying regex...")
// Match <S d="..." /> or <S d="..." r="..." />
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
@@ -877,7 +860,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
}
// Generate media URLs for each segment
for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
@@ -890,9 +872,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Handle manifest-based download (DASH/BTS)
if strings.HasPrefix(downloadURL, "MANIFEST:") {
// Initialize progress tracking for manifest downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -936,7 +916,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -946,24 +925,19 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
@@ -980,7 +954,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -1003,7 +976,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
Timeout: 120 * time.Second,
}
// If we have a direct URL (BTS format), download directly with progress tracking
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile)
@@ -1035,7 +1007,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
expectedSize := resp.ContentLength
// Set total bytes for progress tracking
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -1045,7 +1016,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return fmt.Errorf("failed to create file: %w", err)
}
// Use item progress writer
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
@@ -1068,7 +1038,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -1077,21 +1046,15 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil
}
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
// On Android, we can't use ffmpeg, so we save as M4A directly
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
// We just update progress here based on segment count
out, err := os.Create(m4aPath)
if err != nil {
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
return fmt.Errorf("failed to create M4A file: %w", err)
}
// Download initialization segment
GoLog("[Tidal] Downloading init segment...\n")
if isDownloadCancelled(itemID) {
out.Close()
@@ -1134,7 +1097,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return fmt.Errorf("failed to write init segment: %w", err)
}
// Download media segments with progress
totalSegments := len(mediaURLs)
for i, mediaURL := range mediaURLs {
if isDownloadCancelled(itemID) {
@@ -1147,7 +1109,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
}
// Update progress based on segment count
if itemID != "" {
progress := float64(i+1) / float64(totalSegments)
SetItemProgress(itemID, progress, 0, 0)
+4 -47
View File
@@ -45,7 +45,6 @@ class DownloadHistoryItem {
final int? duration;
final String? releaseDate;
final String? quality;
// Audio quality info (from file after download)
final int? bitDepth;
final int? sampleRate;
@@ -141,7 +140,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
@override
DownloadHistoryState build() {
// Load history from storage on init
_loadFromStorageSync();
return DownloadHistoryState();
}
@@ -165,13 +163,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
.toList();
// Deduplicate existing history on load
final deduplicatedItems = _deduplicateHistory(items);
state = state.copyWith(items: deduplicatedItems);
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
// Save if duplicates were removed
if (deduplicatedItems.length < items.length) {
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
await _saveToStorage();
@@ -194,9 +190,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final item = items[i];
String? key;
// Generate unique key based on available identifiers
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
// Extract numeric ID for deezer: prefixed IDs
if (item.spotifyId!.startsWith('deezer:')) {
key = 'deezer:${item.spotifyId!.substring(7)}';
} else {
@@ -208,11 +202,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (key != null) {
if (!seen.containsKey(key)) {
// First occurrence - keep it (most recent since list is sorted by date desc)
seen[key] = result.length;
result.add(item);
} else {
// Duplicate found - skip (keep the first/most recent one)
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
}
} else {
@@ -241,9 +233,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
void addToHistory(DownloadHistoryItem item) {
// Check if track already exists in history (by spotifyId, deezerId, or ISRC)
final existingIndex = state.items.indexWhere((existing) {
// Match by spotifyId (primary identifier - includes deezer:xxx format)
if (item.spotifyId != null &&
item.spotifyId!.isNotEmpty &&
existing.spotifyId == item.spotifyId) {
@@ -253,14 +243,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
// 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); // Remove "deezer:" prefix
final itemDeezerId = item.spotifyId!.substring(7);
final existingDeezerId = existing.spotifyId!.substring(7);
if (itemDeezerId == existingDeezerId) {
return true;
}
}
// Fallback: match by ISRC if spotifyId not available
if (item.isrc != null &&
item.isrc!.isNotEmpty &&
existing.isrc == item.isrc) {
@@ -279,7 +268,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
} else {
// Add new entry
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
}
@@ -402,7 +390,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_progressTimer = null;
});
// Initialize output directory and load persisted queue asynchronously
Future.microtask(() async {
await _initOutputDir();
await _loadQueueFromStorage();
@@ -432,7 +419,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return item;
}).toList();
// Only restore queued/downloading items (not completed/failed/skipped)
final pendingItems = restoredItems
.where((item) => item.status == DownloadStatus.queued)
.toList();
@@ -461,7 +447,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
final prefs = await SharedPreferences.getInstance();
// Only persist queued and downloading items
final pendingItems = state.items
.where(
(item) =>
@@ -471,7 +456,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.toList();
if (pendingItems.isEmpty) {
// Clear storage if no pending items
await prefs.remove(_queueStorageKey);
_log.d('Cleared queue storage (no pending items)');
} else {
@@ -523,12 +507,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
// Check if status is "finalizing" (embedding metadata)
// Only trust finalizing status if bytesTotal > 0 (download actually happened)
if (status == 'finalizing' && bytesTotal > 0) {
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
// Track finalizing item for notification
final currentItem = state.items
.where((i) => i.id == itemId)
.firstOrNull;
@@ -540,7 +521,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
continue;
}
// Use progress from backend if available (handles both explicit progress and byte-based)
final progressFromBackend =
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
@@ -556,7 +536,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateProgress(itemId, percentage, speedMBps: speedMBps);
// Log progress for each item with speed
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
if (bytesTotal > 0) {
@@ -571,7 +550,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
// Show finalizing notification if any item is finalizing (takes priority)
if (hasFinalizingItem && finalizingTrackName != null) {
_notificationService.showDownloadFinalizing(
trackName: finalizingTrackName,
@@ -592,7 +570,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.where((i) => i.status == DownloadStatus.downloading)
.toList();
if (downloadingItems.isNotEmpty) {
// Show single track name if only 1 download, otherwise show count
final trackName = downloadingItems.length == 1
? downloadingItems.first.track.name
: '${downloadingItems.length} downloads';
@@ -600,12 +577,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? downloadingItems.first.track.artistName
: 'Downloading...';
// Calculate notification progress values
int notifProgress = bytesReceived;
int notifTotal = bytesTotal;
if (bytesTotal <= 0) {
// Fallback to percentage for DASH/unknown size
final progressPercent =
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
notifProgress = (progressPercent * 100).toInt();
@@ -616,10 +591,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1,
);
total: notifTotal > 0 ? notifTotal : 1,
);
// Update foreground service notification (Android)
if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress(
trackName: downloadingItems.first.track.name,
@@ -632,7 +606,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} catch (e) {
// Ignore polling errors
}
});
}
@@ -665,7 +638,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
state = state.copyWith(outputDir: musicDir.path);
} else {
// Fallback to documents directory
final docDir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${docDir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
@@ -675,7 +647,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} catch (e) {
// Fallback for any platform
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
@@ -695,12 +666,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String baseDir = state.outputDir;
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
// If separateSingles is enabled, use Albums/Singles structure
if (separateSingles) {
final isSingle = track.isSingle;
if (isSingle) {
// Singles go to Singles folder (flat structure)
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
final dir = Directory(singlesPath);
if (!await dir.exists()) {
@@ -709,7 +678,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
return singlesPath;
} else {
// Albums folder structure based on setting
final albumName = _sanitizeFolderName(track.albumName);
final artistName = _sanitizeFolderName(albumArtist);
final year = _extractYear(track.releaseDate);
@@ -726,12 +694,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
break;
case 'year_album':
// Albums/[Year] Album structure (no artist folder)
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
break;
default:
// Albums/Artist/Album structure (default: artist_album)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
}
@@ -951,7 +917,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (state.isPaused) {
state = state.copyWith(isPaused: false);
_log.i('Queue resumed');
// If there are still queued items, continue processing
if (state.queuedCount > 0 && !state.isProcessing) {
Future.microtask(() => _processQueue());
}
@@ -995,9 +960,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return i;
}).toList();
state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
_saveQueueToStorage();
// Start processing if not already running
if (!state.isProcessing) {
_log.d('Starting queue processing for retry');
Future.microtask(() => _processQueue());
@@ -1093,7 +1057,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
// Upgrade cover URL to max quality if setting is enabled
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality: $coverUrl');
@@ -1104,7 +1067,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
// Download cover using HTTP
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(coverUrl));
final response = await request.close();
@@ -1125,12 +1087,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
// Use Go backend to embed metadata
try {
// Use FFmpeg to embed cover art AND text metadata
// FFmpeg can embed cover art to FLAC and also set tags
// Construct metadata map
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
@@ -112,7 +112,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
// Invalid JSON, start fresh
state = state.copyWith(isLoaded: true);
}
} else {
@@ -210,10 +209,8 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
// Add new item at the beginning
updatedItems.insert(0, item);
// Limit to max items
if (updatedItems.length > _maxRecentItems) {
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
}
@@ -221,7 +218,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
state = state.copyWith(items: updatedItems);
_saveHistory();
// Debug log
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
}
-10
View File
@@ -22,13 +22,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
// Run migrations if needed
await _runMigrations(prefs);
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
// Sync logging state
LogBuffer.loggingEnabled = state.enableLogging;
}
}
@@ -38,16 +35,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
// Migration 1: Set metadataSource to 'deezer' for existing users
// Only apply if user hasn't enabled custom Spotify credentials
// (users with custom credentials likely prefer Spotify)
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
// Save current migration version
if (lastMigration < _currentMigrationVersion) {
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
}
@@ -68,8 +61,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
state.spotifyClientSecret,
);
}
// Note: If credentials are empty, Spotify API will return error
// User should use Deezer as metadata source instead
}
void setDefaultService(String service) {
@@ -113,7 +104,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setConcurrentDownloads(int count) {
// Clamp between 1 and 3
final clamped = count.clamp(1, 3);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
+3 -25
View File
@@ -142,14 +142,11 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during fetch
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// First, check if any extension can handle this URL
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -188,7 +185,6 @@ class TrackNotifier extends Notifier<TrackState> {
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
@@ -209,13 +205,11 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// No extension handler found, try Spotify URL parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
final type = parsed['type'] as String;
// Use the new fallback-enabled method
Map<String, dynamic> metadata;
try {
@@ -225,7 +219,6 @@ class TrackNotifier extends Notifier<TrackState> {
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) {
// If fallback also fails, show error
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow;
@@ -252,7 +245,6 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
// Pre-warm cache for album tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -281,8 +273,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
// Preserve hasSearchText on error so user stays on search screen
if (!_isRequestValid(requestId)) return;
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
@@ -295,7 +286,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Check if extension providers should be used for search
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any(
@@ -308,7 +298,6 @@ class TrackNotifier extends Notifier<TrackState> {
searchProvider != null &&
searchProvider.isNotEmpty;
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
_log.i(
@@ -318,14 +307,12 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> results;
List<Track> extensionTracks = [];
// Try extension providers first if enabled
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
_log.i('Extensions returned ${extResults.length} tracks');
// Parse extension results
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
@@ -338,7 +325,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Also search with built-in providers
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
@@ -365,7 +351,6 @@ class TrackNotifier extends Notifier<TrackState> {
// Add extension tracks first (they have priority)
tracks.addAll(extensionTracks);
// Add built-in provider tracks, avoiding duplicates by ISRC
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
@@ -376,7 +361,6 @@ class TrackNotifier extends Notifier<TrackState> {
try {
if (t is Map<String, dynamic>) {
final track = _parseSearchTrack(t);
// Skip if we already have this track from extensions
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
continue;
}
@@ -389,7 +373,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Parse artists with error handling per item
final artists = <SearchArtist>[];
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
@@ -439,7 +422,6 @@ class TrackNotifier extends Notifier<TrackState> {
_log.i('Custom search returned ${results.length} tracks');
// Parse tracks with error handling per item, setting source to extension ID
final tracks = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
@@ -563,7 +545,6 @@ class TrackNotifier extends Notifier<TrackState> {
durationMs = durationValue.toInt();
}
// Get item_type - can be 'track', 'album', or 'playlist'
final itemType = data['item_type']?.toString();
return Track(
@@ -620,13 +601,10 @@ class TrackNotifier extends Notifier<TrackState> {
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal', // Default to tidal for pre-warming
'service': 'tidal',
}).toList();
// Fire and forget - runs in background
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
// Silently ignore errors - this is just an optimization
});
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
}
}
-12
View File
@@ -52,11 +52,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final Set<String> _pendingChecks = {};
static const int _maxCacheSize = 500;
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
// Filter page controller for swipe between All/Albums/Singles
PageController? _filterPageController;
final List<String> _filterModes = ['all', 'albums', 'singles'];
bool _isPageControllerInitialized = false;
@@ -66,7 +64,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@override
void initState() {
super.initState();
// Will be initialized in build when we have access to ref
}
void _initializePageController() {
@@ -291,7 +288,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
) {
if (filterMode == 'all') return items;
// Count tracks per album
final albumCounts = <String, int>{};
for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
@@ -307,7 +303,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return (albumCounts[key] ?? 0) > 1;
}).toList();
case 'singles':
// Single = only 1 track from that album in history
return items.where((item) {
final key =
'${item.albumName}|${item.albumArtist ?? item.artistName}';
@@ -320,7 +315,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
/// Count albums vs singles for filter chips
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
// Count tracks per album
final albumCounts = <String, int>{};
for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
@@ -351,11 +345,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
albumMap.putIfAbsent(key, () => []).add(item);
}
// Only include albums with more than 1 track
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map(
(e) {
final tracks = e.value;
// Sort tracks by track number
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
@@ -374,7 +366,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
},
).toList();
// Sort by latest download
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
return groupedAlbums;
@@ -447,10 +438,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
// Group albums for Albums filter view
final groupedAlbums = _groupByAlbum(allHistoryItems);
// Count for filter chips
final counts = _countAlbumsAndSingles(allHistoryItems);
final albumCount = _countUniqueAlbums(allHistoryItems);
final singleCount = counts['singles'] ?? 0;
@@ -468,7 +457,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
children: [
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
// App Bar - always normal style
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
+1 -18
View File
@@ -18,7 +18,6 @@ class AboutPage extends StatelessWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -35,9 +34,7 @@ class AboutPage extends StatelessWidget {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
// When collapsed (expandRatio=0): left=56 to avoid back button
// When expanded (expandRatio=1): left=24 for normal padding
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
@@ -62,7 +59,6 @@ class AboutPage extends StatelessWidget {
),
),
// Contributors section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
),
@@ -91,7 +87,6 @@ class AboutPage extends StatelessWidget {
),
),
// Special Thanks section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
@@ -128,7 +123,6 @@ class AboutPage extends StatelessWidget {
),
),
// Links section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
@@ -167,7 +161,6 @@ class AboutPage extends StatelessWidget {
),
),
// Support section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
@@ -185,7 +178,6 @@ class AboutPage extends StatelessWidget {
),
),
// App info section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
@@ -202,7 +194,6 @@ class AboutPage extends StatelessWidget {
),
),
// Copyright
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -227,7 +218,6 @@ class AboutPage extends StatelessWidget {
static Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
// Use inAppBrowserView for reliable URL opening with app chooser
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
@@ -275,7 +265,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
// App name
Text(
AppInfo.appName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -283,7 +272,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 4),
// Version badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
@@ -299,7 +287,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
// Description
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
@@ -341,7 +328,6 @@ class _ContributorItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// GitHub Avatar
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
@@ -372,7 +358,6 @@ class _ContributorItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Name and description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -391,7 +376,6 @@ class _ContributorItem extends StatelessWidget {
],
),
),
// GitHub icon
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
@@ -446,7 +430,6 @@ class _AboutSettingsItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// Icon with 40x40 size to match avatar
SizedBox(
width: 40,
height: 40,
@@ -21,7 +21,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -50,7 +49,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Color section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionColor),
),
@@ -80,10 +78,9 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
),
// Theme section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
),
@@ -109,7 +106,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Language section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
),
@@ -126,7 +122,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Layout section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
),
@@ -143,7 +138,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Fill remaining for scroll
const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(height: 32),
@@ -174,7 +168,6 @@ class _ThemePreviewCard extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Decorative background blobs
Positioned(
top: -50,
right: -50,
@@ -200,7 +193,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
),
// Foreground "fake UI"
Center(
child: Container(
width: 260,
@@ -235,7 +227,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
const SizedBox(width: 16),
// Fake Text Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -288,7 +279,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
),
// Label badge
Positioned(
bottom: 12,
right: 12,
@@ -510,10 +500,7 @@ class _ThemeModeChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@@ -732,15 +719,12 @@ class _LanguageSelector extends StatelessWidget {
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
List<(String, String, IconData)> get _languages {
return _allLanguages.where((lang) {
// Always include 'system' option
if (lang.$1 == 'system') return true;
// Only include languages in the filtered set
return filteredLocaleCodes.contains(lang.$1);
}).toList();
}
String _getLanguageName(String code) {
// Search in all languages (not just filtered) for display name fallback
for (final lang in _allLanguages) {
if (lang.$1 == code) return lang.$2;
}
@@ -11,7 +11,6 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerWidget {
const DownloadSettingsPage({super.key});
// Built-in services that support quality options
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
@override
@@ -20,7 +19,6 @@ class DownloadSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
// Check if current service is built-in (supports quality options)
final isBuiltInService = _builtInServices.contains(settings.defaultService);
return PopScope(
@@ -28,7 +26,6 @@ class DownloadSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -85,7 +82,6 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// Quality section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
),
@@ -99,7 +95,6 @@ class DownloadSettingsPage extends ConsumerWidget {
? context.l10n.downloadAskQualitySubtitle
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
// Not selected visually if extension is active
enabled: isBuiltInService,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -159,7 +154,6 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// File settings section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
),
@@ -321,11 +315,9 @@ class DownloadSettingsPage extends ConsumerWidget {
String insertion = tag;
if (start > 0) {
final before = text.substring(0, start);
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
if (!before.trim().endsWith('-')) {
insertion = ' - $tag';
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
// If ends with '-' but no space, add space
insertion = ' $tag';
}
}
@@ -697,12 +689,10 @@ class _ServiceSelector extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
// Get enabled extension download providers
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
.toList();
// Check if current service is an extension that's now disabled
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService)
@@ -739,7 +729,6 @@ class _ServiceSelector extends ConsumerWidget {
),
],
),
// Show extension download providers if any
if (extensionProviders.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
@@ -755,7 +744,6 @@ class _ServiceSelector extends ConsumerWidget {
),
),
],
// Fill remaining space if less than 3 extensions
for (int i = extensionProviders.length; i < 3; i++) ...[
const SizedBox(width: 8),
const Expanded(child: SizedBox()),
@@ -62,7 +62,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -98,7 +97,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// Extension Info Card
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -202,7 +200,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// Capabilities
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
),
@@ -254,9 +251,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// URL Handler Section (if extension handles URLs)
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
@@ -272,7 +266,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Quality Options Section (for download providers)
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
@@ -291,7 +284,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Post-Processing Hooks (if available)
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
@@ -310,7 +302,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Permissions
if (extension.permissions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
@@ -329,7 +320,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Settings
if (extension.settings.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
@@ -358,7 +348,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Remove button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -424,7 +413,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
.read(extensionProvider.notifier)
.removeExtension(widget.extensionId);
if (success && mounted) {
// Refresh store to update isInstalled status
ref.read(storeProvider.notifier).refresh();
Navigator.pop(this.context);
}
@@ -557,7 +545,6 @@ class _PermissionItem extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Parse permission to get icon and description
IconData icon = Icons.security;
String description = permission;
-18
View File
@@ -32,7 +32,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if they don't exist
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
@@ -87,7 +86,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Loading indicator
if (extState.isLoading)
const SliverToBoxAdapter(
child: Padding(
@@ -96,7 +94,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Error message
if (extState.error != null)
SliverToBoxAdapter(
child: Padding(
@@ -137,7 +134,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Installed Extensions
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
),
@@ -203,7 +199,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Install button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -284,11 +279,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
if (success) {
message = context.l10n.extensionsInstalledSuccess;
} else {
// Parse friendly error message
message = _getFriendlyErrorMessage(extState.error);
}
// Clear the error from state to avoid showing it twice (in error container)
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(context).showSnackBar(
@@ -305,15 +298,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
String message = error;
// Remove PlatformException wrapper if present
// Format: PlatformException(ERROR, actual message, null, null)
if (message.contains('PlatformException')) {
// Try to extract the actual error message
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
if (match != null) {
message = match.group(1)?.trim() ?? message;
} else {
// Fallback: try simpler extraction
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message;
@@ -321,7 +310,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
}
// Clean up any remaining artifacts
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
@@ -390,7 +378,6 @@ class _ExtensionItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -445,7 +432,6 @@ class _DownloadPriorityItem extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Check if any extension has download provider
final hasDownloadExtensions = extState.extensions
.any((e) => e.enabled && e.hasDownloadProvider);
@@ -584,12 +570,10 @@ class _SearchProviderSelector extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Get extensions with custom search
final searchProviders = extState.extensions
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
// Get current provider name
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
@@ -689,7 +673,6 @@ class _SearchProviderSelector extends ConsumerWidget {
),
),
),
// Default option
ListTile(
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: Text(ctx.l10n.extensionDefaultProvider),
@@ -702,7 +685,6 @@ class _SearchProviderSelector extends ConsumerWidget {
Navigator.pop(ctx);
},
),
// Extension options
...searchProviders.map((ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
-22
View File
@@ -25,14 +25,12 @@ class _LogScreenState extends State<LogScreen> {
void initState() {
super.initState();
LogBuffer().addListener(_onLogUpdate);
// Start polling Go backend logs
LogBuffer().startGoLogPolling();
}
@override
void dispose() {
LogBuffer().removeListener(_onLogUpdate);
// Stop polling when leaving screen
LogBuffer().stopGoLogPolling();
_scrollController.dispose();
_searchController.dispose();
@@ -131,7 +129,6 @@ class _LogScreenState extends State<LogScreen> {
body: CustomScrollView(
controller: _scrollController,
slivers: [
// Collapsing App Bar with back button - same as other settings pages
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -208,7 +205,6 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Filter section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
),
@@ -269,7 +265,6 @@ class _LogScreenState extends State<LogScreen> {
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
@@ -323,12 +318,10 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Error summary card - shows detected issues
SliverToBoxAdapter(
child: _LogSummaryCard(logs: LogBuffer().entries),
),
// Log list
logs.isEmpty
? SliverToBoxAdapter(
child: SettingsGroup(
@@ -379,7 +372,6 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
@@ -418,7 +410,6 @@ class _LogEntryTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: time, level, tag
Row(
children: [
Text(
@@ -478,7 +469,6 @@ class _LogEntryTile extends StatelessWidget {
],
),
const SizedBox(height: 6),
// Message
Text(
entry.message,
style: TextStyle(
@@ -488,7 +478,6 @@ class _LogEntryTile extends StatelessWidget {
height: 1.4,
),
),
// Error if present
if (entry.error != null) ...[
const SizedBox(height: 4),
Text(
@@ -526,10 +515,8 @@ class _LogSummaryCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Analyze logs for issues
final analysis = _analyzeLogs();
// Don't show if no issues detected
if (!analysis.hasIssues) {
return const SizedBox.shrink();
}
@@ -547,7 +534,6 @@ class _LogSummaryCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
@@ -567,7 +553,6 @@ class _LogSummaryCard extends StatelessWidget {
),
const SizedBox(height: 12),
// ISP Blocking detected
if (analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.block,
@@ -580,7 +565,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Rate limiting
if (analysis.hasRateLimit) ...[
_IssueBadge(
icon: Icons.speed,
@@ -592,7 +576,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Network errors
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.wifi_off,
@@ -604,7 +587,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Track not found
if (analysis.hasNotFound) ...[
_IssueBadge(
icon: Icons.search_off,
@@ -615,7 +597,6 @@ class _LogSummaryCard extends StatelessWidget {
),
],
// Error count
const SizedBox(height: 12),
Text(
'Total errors: ${analysis.errorCount}',
@@ -655,7 +636,6 @@ class _LogSummaryCard extends StatelessWidget {
combined.contains('connection refused')) {
hasISPBlocking = true;
// Try to extract domain
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!);
@@ -669,7 +649,6 @@ class _LogSummaryCard extends StatelessWidget {
hasRateLimit = true;
}
// Check for network errors
if (combined.contains('connection') ||
combined.contains('timeout') ||
combined.contains('network') ||
@@ -677,7 +656,6 @@ class _LogSummaryCard extends StatelessWidget {
hasNetworkError = true;
}
// Check for not found
if (combined.contains('not found') ||
combined.contains('no results') ||
combined.contains('could not find')) {
@@ -24,16 +24,13 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
// Use saved priority if available, otherwise use default order
if (extState.metadataProviderPriority.isNotEmpty) {
_providers = List.from(extState.metadataProviderPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
@@ -57,7 +54,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -109,7 +105,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -122,7 +117,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Provider list
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
@@ -150,7 +144,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -258,7 +251,6 @@ class _MetadataProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Priority number
Container(
width: 28,
height: 28,
@@ -281,7 +273,6 @@ class _MetadataProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
@@ -289,7 +280,6 @@ class _MetadataProviderItem extends StatelessWidget {
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -309,7 +299,6 @@ class _MetadataProviderItem extends StatelessWidget {
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
@@ -339,7 +328,6 @@ class _MetadataProviderItem extends StatelessWidget {
isBuiltIn: true,
);
default:
// Extension provider
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
@@ -23,7 +23,6 @@ class OptionsSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -63,7 +62,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Search Source section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
),
@@ -77,7 +75,6 @@ class OptionsSettingsPage extends ConsumerWidget {
.setMetadataSource(v),
),
if (settings.metadataSource == 'spotify') ...[
// Info card about Spotify credentials requirement
if (settings.spotifyClientId.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -130,7 +127,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Download options section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
@@ -179,7 +175,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Performance section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
),
@@ -196,7 +191,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// App section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionApp),
),
@@ -230,7 +224,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Data section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionData),
),
@@ -249,7 +242,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Debug section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
),
@@ -370,7 +362,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 32),
// Client ID
TextField(
controller: clientIdController,
decoration: InputDecoration(
@@ -408,7 +399,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 16),
// Client Secret
TextField(
controller: clientSecretController,
obscureText: true,
@@ -804,7 +794,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
// Check if extension search provider is active AND enabled
Extension? activeExtension;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
activeExtension = extState.extensions
@@ -860,10 +849,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.music_note,
label: 'Spotify',
// Not selected if extension is active
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
onTap: () {
// If extension was active, reset it to default
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
@@ -24,17 +24,13 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
// Use saved priority if available, otherwise use default order
if (extState.providerPriority.isNotEmpty) {
// Start with saved priority
_providers = List.from(extState.providerPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
@@ -58,7 +54,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -110,7 +105,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -123,7 +117,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Provider list
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
@@ -151,7 +144,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -246,7 +238,6 @@ class _ProviderItem extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
// Get provider info
final info = _getProviderInfo(provider);
return Padding(
@@ -260,7 +251,6 @@ class _ProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Priority number
Container(
width: 28,
height: 28,
@@ -283,7 +273,6 @@ class _ProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
@@ -291,7 +280,6 @@ class _ProviderItem extends StatelessWidget {
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -311,7 +299,6 @@ class _ProviderItem extends StatelessWidget {
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
@@ -345,7 +332,6 @@ class _ProviderItem extends StatelessWidget {
isBuiltIn: true,
);
default:
// Extension provider
return _ProviderInfo(
name: provider,
icon: Icons.extension,
-4
View File
@@ -20,7 +20,6 @@ class SettingsTab extends ConsumerWidget {
return CustomScrollView(
slivers: [
// Collapsing App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -54,7 +53,6 @@ class SettingsTab extends ConsumerWidget {
),
),
// First group: Appearance & Download
SliverToBoxAdapter(
child: Builder(
builder: (context) {
@@ -94,7 +92,6 @@ class SettingsTab extends ConsumerWidget {
),
),
// Second group: Logs & About
SliverToBoxAdapter(
child: Builder(
builder: (context) {
@@ -120,7 +117,6 @@ class SettingsTab extends ConsumerWidget {
),
),
// Fill remaining space
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
);
-32
View File
@@ -25,13 +25,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool _isLoading = false;
int _androidSdkVersion = 0;
// Spotify API credentials
final _clientIdController = TextEditingController();
final _clientSecretController = TextEditingController();
bool _useSpotifyApi = false;
bool _showClientSecret = false;
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
@override
@@ -66,17 +64,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
});
}
} else if (Platform.isAndroid) {
// Check storage permission
bool storageGranted = false;
if (_androidSdkVersion >= 33) {
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
final manageStatus = await Permission.manageExternalStorage.status;
final audioStatus = await Permission.audio.status;
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) {
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
final manageStatus = await Permission.manageExternalStorage.status;
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
storageGranted = manageStatus.isGranted;
@@ -89,7 +84,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
debugPrint('[Permission] Final storageGranted=$storageGranted');
// Check notification permission (Android 13+)
PermissionStatus notificationStatus = PermissionStatus.granted;
if (_androidSdkVersion >= 33) {
notificationStatus = await Permission.notification.status;
@@ -115,9 +109,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool allGranted = false;
if (_androidSdkVersion >= 33) {
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
// First check/request MANAGE_EXTERNAL_STORAGE
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
if (mounted) {
@@ -144,14 +135,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
// Re-check after returning from settings
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
}
// Then request READ_MEDIA_AUDIO (this shows a dialog)
var audioStatus = await Permission.audio.status;
if (!audioStatus.isGranted && manageStatus.isGranted) {
audioStatus = await Permission.audio.request();
@@ -160,7 +149,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
allGranted = manageStatus.isGranted && audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) {
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
if (mounted) {
@@ -187,7 +175,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
// Re-check after returning from settings
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
@@ -239,7 +226,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_showPermissionDeniedDialog('Notification');
}
} else {
// Notification permission not needed for older Android
setState(() => _notificationPermissionGranted = true);
}
} catch (e) {
@@ -286,7 +272,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
// iOS: Show options dialog
await _showIOSDirectoryOptions();
} else {
// Android: Use file picker
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: context.l10n.setupSelectDownloadFolder,
);
@@ -359,7 +344,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
setState(() => _selectedDirectory = result);
@@ -444,10 +428,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_clientIdController.text.trim(),
_clientSecretController.text.trim(),
);
// Set search source to Spotify when credentials are provided
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
} else {
// Use Deezer as default search source (free, no credentials required)
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
}
@@ -482,7 +464,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Top section - Logo/Title
Column(
children: [
const SizedBox(height: 24),
@@ -501,7 +482,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
],
),
// Middle section - Steps and Content
Column(
children: [
const SizedBox(height: 24),
@@ -511,7 +491,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
],
),
// Bottom section - Navigation Buttons
Column(
children: [
const SizedBox(height: 24),
@@ -637,7 +616,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -691,7 +669,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -754,7 +731,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -829,7 +805,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -860,7 +835,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 24),
// Toggle card (M3 style)
Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
@@ -891,7 +865,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
),
// Credentials form (animated)
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
@@ -906,7 +879,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Client ID
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
@@ -925,7 +897,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 16),
// Client Secret
Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
@@ -983,14 +954,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final isLastStep = _currentStep == _totalSteps - 1;
final canProceed = _isStepCompleted(_currentStep);
// For Spotify step, check if credentials are valid when enabled
final isSpotifyStepValid = !_useSpotifyApi ||
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Back button
if (_currentStep > 0)
TextButton.icon(
onPressed: () => setState(() => _currentStep--),
@@ -1003,7 +972,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
else
const SizedBox(width: 100),
// Next/Finish button
if (!isLastStep)
FilledButton(
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
@@ -20,11 +20,8 @@ class _ExtensionDetailsScreenState
@override
Widget build(BuildContext context) {
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
final storeState = ref.watch(storeProvider);
// Find our extension in the store state to get the latest status
// If not found in current store state (rare), fallback to widget.extension
final liveExtension =
storeState.extensions
.where((e) => e.id == widget.extension.id)
@@ -188,7 +185,6 @@ class _ExtensionDetailsScreenState
const SizedBox(height: 16),
// Badges row
Wrap(
spacing: 8,
runSpacing: 8,
@@ -215,7 +211,6 @@ class _ExtensionDetailsScreenState
const SizedBox(height: 24),
// Action Buttons
if (isDownloading)
Center(
child: CircularProgressIndicator(
@@ -410,7 +405,6 @@ class _ExtensionDetailsScreenState
StoreExtension ext,
ColorScheme colorScheme,
) {
// Determine capabilities based on category
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
final isDownloadProvider = ext.category == 'download';
final isLyricsProvider = ext.category == 'lyrics';
+1 -14
View File
@@ -29,7 +29,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final cacheDir = await getApplicationCacheDirectory();
// Check if widget is still mounted after async operation
if (!mounted) return;
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
@@ -53,7 +52,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
child: CustomScrollView(
slivers: [
// App Bar - consistent with other tabs
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -87,7 +85,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Search Bar
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -131,7 +128,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Category Chips
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
@@ -203,7 +199,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Content
if (state.isLoading && state.extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
@@ -215,7 +210,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
else if (state.filteredExtensions.isEmpty)
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
else ...[
// Extensions count
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -228,7 +222,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Extensions list in grouped card (like queue_tab)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -252,7 +245,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
],
@@ -457,7 +449,6 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon - custom or category-based
Container(
width: 44,
height: 44,
@@ -507,7 +498,6 @@ class _ExtensionItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -518,10 +508,9 @@ class _ExtensionItem extends StatelessWidget {
child: Text(
extension.displayName,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w500),
?.copyWith(fontWeight: FontWeight.w500 ),
),
),
// Version badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@@ -548,7 +537,6 @@ class _ExtensionItem extends StatelessWidget {
color: colorScheme.onSurfaceVariant,
),
),
// Warning badge for incompatible extensions
if (extension.requiresNewerApp) ...[
const SizedBox(height: 4),
Container(
@@ -587,7 +575,6 @@ class _ExtensionItem extends StatelessWidget {
),
),
const SizedBox(width: 12),
// Action button
if (isDownloading)
const SizedBox(
width: 24,
-31
View File
@@ -44,7 +44,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _checkFile() async {
// Strip EXISTS: prefix from legacy history items
var filePath = widget.item.filePath;
if (filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7);
@@ -66,14 +65,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_fileSize = size;
});
// Auto-load lyrics if file exists (embedded lyrics are instant)
if (exists) {
_fetchLyrics();
}
}
}
// Use data directly from history item (cached from download)
DownloadHistoryItem get item => widget.item;
String get trackName => item.trackName;
String get artistName => item.artistName;
@@ -84,7 +81,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
// Clean filePath - strip EXISTS: prefix from legacy history items
String get cleanFilePath {
final path = item.filePath;
return path.startsWith('EXISTS:') ? path.substring(7) : path;
@@ -99,7 +95,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar with cover art background
SliverAppBar(
expandedHeight: 280,
pinned: true,
@@ -138,34 +133,28 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
],
),
// Content
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track info card
_buildTrackInfoCard(context, colorScheme, _fileExists),
const SizedBox(height: 16),
// Metadata card
_buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
// File info card
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
const SizedBox(height: 16),
// Lyrics card
_buildLyricsCard(context, colorScheme),
const SizedBox(height: 24),
// Action buttons
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
@@ -182,7 +171,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Stack(
fit: StackFit.expand,
children: [
// Blurred background
if (item.coverUrl != null)
CachedNetworkImage(
imageUrl: item.coverUrl!,
@@ -191,7 +179,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
colorBlendMode: BlendMode.darken,
),
// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -207,7 +194,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
// Cover art centered
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
@@ -268,7 +254,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track name (from file metadata)
Text(
trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -278,7 +263,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 4),
// Artist name (from file metadata)
Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
@@ -287,7 +271,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 8),
// Album name (from file metadata)
Row(
children: [
Icon(
@@ -372,10 +355,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 16),
// Metadata grid
_buildMetadataGrid(context, colorScheme),
// Streaming service link button
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
const SizedBox(height: 8),
Builder(
@@ -416,28 +397,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
: Uri.parse('spotify:track:$rawId');
try {
// Try to open in App first using URI scheme
final launched = await launchUrl(
appUri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
// Fallback to web URL which will redirect to app if installed
await launchUrl(
Uri.parse(webUrl),
mode: LaunchMode.externalApplication,
);
}
} catch (e) {
// If URI scheme fails, try web URL
try {
await launchUrl(
Uri.parse(webUrl),
mode: LaunchMode.externalApplication,
);
} catch (_) {
// Last resort: copy to clipboard
if (context.mounted) {
_copyToClipboard(context, webUrl);
ScaffoldMessenger.of(context).showSnackBar(
@@ -449,7 +426,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Build audio quality string from file metadata
String? audioQualityStr;
if (bitDepth != null && sampleRate != null) {
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
@@ -568,7 +544,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 16),
// Format chip
Wrap(
spacing: 8,
runSpacing: 8,
@@ -651,7 +626,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(height: 16),
// File path
InkWell(
onTap: () => _copyToClipboard(context, cleanFilePath),
borderRadius: BorderRadius.circular(12),
@@ -811,7 +785,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_lyricsLoading = false;
});
} else {
// Clean up LRC timestamps for display
final cleanLyrics = _cleanLrcForDisplay(result);
setState(() {
_lyrics = cleanLyrics;
@@ -851,7 +824,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
return Row(
children: [
// Play button
Expanded(
flex: 2,
child: FilledButton.icon(
@@ -868,7 +840,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 12),
// Delete button
Expanded(
child: OutlinedButton.icon(
onPressed: () => _confirmDelete(context, ref, colorScheme),
@@ -951,7 +922,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
TextButton(
onPressed: () async {
// Delete the file first
try {
final file = File(cleanFilePath);
if (await file.exists()) {
@@ -961,7 +931,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
debugPrint('Failed to delete file: $e');
}
// Remove from history
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
if (context.mounted) {
-3
View File
@@ -14,7 +14,6 @@ class ApkDownloader {
required String version,
ProgressCallback? onProgress,
}) async {
// Validate URL for security
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != 'https') {
_log.e('Refusing to download from invalid or non-HTTPS URL');
@@ -35,7 +34,6 @@ class ApkDownloader {
final contentLength = response.contentLength ?? 0;
// Get download directory
final dir = await getExternalStorageDirectory();
if (dir == null) {
_log.e('Could not get storage directory');
@@ -45,7 +43,6 @@ class ApkDownloader {
final filePath = '${dir.path}/SpotiFLAC-$version.apk';
final file = File(filePath);
// Delete if exists
if (await file.exists()) {
await file.delete();
}
+1 -14
View File
@@ -23,7 +23,6 @@ class CsvImportService {
final content = await file.readAsString();
final tracks = _parseCsv(content);
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
}
@@ -48,7 +47,6 @@ class CsvImportService {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
// Only enrich if missing cover/duration
if (track.coverUrl == null || track.duration == 0) {
Map<String, dynamic>? trackData;
@@ -62,7 +60,6 @@ class CsvImportService {
}
}
// Fallback to text search if ISRC failed or not available
if (trackData == null) {
try {
final query = '${track.artistName} ${track.name}';
@@ -71,13 +68,11 @@ class CsvImportService {
if (searchResult.containsKey('tracks')) {
final tracksList = searchResult['tracks'] as List<dynamic>?;
if (tracksList != null && tracksList.isNotEmpty) {
// Find best match by comparing names
for (final result in tracksList) {
final resultMap = result as Map<String, dynamic>;
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
final trackNameLower = track.name.toLowerCase();
// Check if track name matches (contains or equals)
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName');
@@ -85,7 +80,6 @@ class CsvImportService {
}
}
// If no exact match, use first result
if (trackData == null && tracksList.isNotEmpty) {
trackData = tracksList.first as Map<String, dynamic>;
_log.d('Using first search result for ${track.name}');
@@ -97,7 +91,6 @@ class CsvImportService {
}
}
// Apply enriched data if found
if (trackData != null) {
final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0;
@@ -127,7 +120,6 @@ class CsvImportService {
}
}
// Keep original track if enrichment failed or not needed
enrichedTracks.add(track);
}
@@ -137,10 +129,9 @@ class CsvImportService {
static List<Track> _parseCsv(String content) {
final List<Track> tracks = [];
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
final lines = content.split(RegExp(r'\r\n|\r|\n'));
if (lines.isEmpty) return tracks;
// Detect headers line (assume first non-empty line)
int startIdx = 0;
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
startIdx++;
@@ -150,7 +141,6 @@ class CsvImportService {
final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) {
// Normalize header: lowercase, trim, remove quotes
String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i;
}
@@ -164,7 +154,6 @@ class CsvImportService {
final values = _parseLine(line);
// Helper to get value securely
String? getVal(List<String> keys) {
return _getValue(values, colMap, keys);
}
@@ -180,7 +169,6 @@ class CsvImportService {
spotifyId = spotifyId.replaceAll('spotify:track:', '');
}
// Basic validation: Need at least name and artist, OR a spotify ID
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
tracks.add(Track(
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
@@ -215,7 +203,6 @@ class CsvImportService {
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
val = val.substring(1, val.length - 1);
}
// Handle double quotes escape in CSV ("" -> ")
val = val.replaceAll('""', '"');
return val;
}
-15
View File
@@ -31,14 +31,12 @@ class FFmpegService {
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac');
// FFmpeg command to remux M4A to FLAC
final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
// Delete original M4A file
try {
await File(inputPath).delete();
} catch (_) {}
@@ -88,18 +86,15 @@ class FFmpegService {
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}M4A';
// Create output directory
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
String command;
if (codec == 'alac') {
// ALAC - lossless
command =
'-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
} else {
// AAC - lossy
command =
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
@@ -141,25 +136,19 @@ class FFmpegService {
String? coverPath,
Map<String, String>? metadata,
}) async {
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
// Use app-internal cache directory for temp output
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
// Construct command
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
// Add cover input if available
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
// Map audio stream
cmdBuffer.write('-map 0:a ');
// Map cover stream if available
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
@@ -168,13 +157,10 @@ class FFmpegService {
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
// Copy audio codec (don't re-encode)
cmdBuffer.write('-c:a copy ');
// Add text metadata
if (metadata != null) {
metadata.forEach((key, value) {
// Sanitize value: escape double quotes
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
@@ -215,7 +201,6 @@ class FFmpegService {
}
}
// Clean up temp file if exists
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
-2
View File
@@ -32,7 +32,6 @@ class NotificationService {
await _notifications.initialize(initSettings);
// Create notification channel for Android
if (Platform.isAndroid) {
await _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
@@ -227,7 +226,6 @@ class NotificationService {
await _notifications.cancel(downloadProgressId);
}
// Update APK download notifications
Future<void> showUpdateDownloadProgress({
required String version,
required int received,
-1
View File
@@ -770,7 +770,6 @@ class PlatformBridge {
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
// No extension found or error handling URL
return null;
}
}
-7
View File
@@ -30,13 +30,11 @@ class ShareIntentService {
if (_initialized) return;
_initialized = true;
// Listen to media sharing coming from outside the app while the app is in memory
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
_handleSharedMedia,
onError: (err) => _log.e('Error: $err'),
);
// Get the media sharing coming from outside the app while the app is closed
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
if (initialMedia.isNotEmpty) {
_handleSharedMedia(initialMedia, isInitial: true);
@@ -47,14 +45,12 @@ class ShareIntentService {
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
for (final file in files) {
// Check the path - for text shares, the path contains the shared text
final textToCheck = file.path;
final url = _extractSpotifyUrl(textToCheck);
if (url != null) {
_log.i('Received Spotify URL: $url (initial: $isInitial)');
if (isInitial) {
// Store for later - listener might not be ready yet
_pendingUrl = url;
}
_sharedUrlController.add(url);
@@ -71,18 +67,15 @@ class ShareIntentService {
String? _extractSpotifyUrl(String text) {
if (text.isEmpty) return null;
// Check for spotify: URI format
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
if (uriMatch != null) {
return uriMatch.group(0);
}
// Check for open.spotify.com URL
final urlMatch = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
).firstMatch(text);
if (urlMatch != null) {
// Return URL without query params for cleaner handling
final fullUrl = urlMatch.group(0)!;
final queryIndex = fullUrl.indexOf('?');
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
-4
View File
@@ -65,7 +65,6 @@ class UpdateChecker {
Map<String, dynamic>? releaseData;
if (channel == 'preview') {
// For preview channel, get all releases and find the latest (including prereleases)
final response = await http.get(
Uri.parse('$_allReleasesApiUrl?per_page=10'),
headers: {'Accept': 'application/vnd.github.v3+json'},
@@ -82,10 +81,8 @@ class UpdateChecker {
return null;
}
// First release is the latest (including prereleases)
releaseData = releases.first as Map<String, dynamic>;
} else {
// For stable channel, use /latest endpoint (excludes prereleases)
final response = await http.get(
Uri.parse(_latestApiUrl),
headers: {'Accept': 'application/vnd.github.v3+json'},
@@ -124,7 +121,6 @@ class UpdateChecker {
final name = (asset['name'] as String? ?? '').toLowerCase();
if (name.endsWith('.apk')) {
final downloadUrl = asset['browser_download_url'] as String?;
// Only accept HTTPS URLs for security
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
-2
View File
@@ -64,7 +64,6 @@ class CollapsingHeader extends StatelessWidget {
),
),
// Info card if provided
if (infoCard != null)
SliverToBoxAdapter(
child: Padding(
@@ -73,7 +72,6 @@ class CollapsingHeader extends StatelessWidget {
),
),
// Content slivers
...slivers,
],
);
-12
View File
@@ -105,20 +105,17 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
// Check if it's a built-in service
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
if (builtIn != null) {
return builtIn.qualityOptions;
}
// Check if it's an extension
final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) {
return ext.qualityOptions;
}
// Default quality options if extension doesn't specify any
return const [
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
];
@@ -129,7 +126,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
final colorScheme = Theme.of(context).colorScheme;
final extensionState = ref.watch(extensionProvider);
// Get enabled download provider extensions
final downloadExtensions = extensionState.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.toList();
@@ -142,7 +138,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track info header (if provided)
if (widget.trackName != null) ...[
_TrackInfoHeader(
trackName: widget.trackName!,
@@ -164,7 +159,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
],
// Service selector section
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
@@ -173,21 +167,18 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Built-in services
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Built-in services
for (final service in _builtInServices)
_ServiceChip(
label: service.label,
isSelected: _selectedService == service.id,
onTap: () => setState(() => _selectedService = service.id),
),
// Extension services
for (final ext in downloadExtensions)
_ServiceChip(
label: ext.displayName,
@@ -199,7 +190,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Quality selector section
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
@@ -208,7 +198,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Disclaimer for built-in services
if (_builtInServices.any((s) => s.id == _selectedService))
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
@@ -221,7 +210,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Quality options
for (final quality in qualityOptions)
_QualityOption(
title: quality.label,
-17
View File
@@ -30,7 +30,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
Future<void> _downloadAndInstall() async {
final apkUrl = widget.updateInfo.apkDownloadUrl;
// If no direct APK URL, open release page
if (apkUrl == null) {
final uri = Uri.parse(widget.updateInfo.downloadUrl);
if (await canLaunchUrl(uri)) {
@@ -60,7 +59,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
_statusText = '$receivedMB / $totalMB MB';
});
}
// Update notification
notificationService.showUpdateDownloadProgress(
version: widget.updateInfo.version,
received: received,
@@ -70,7 +68,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
);
if (filePath != null) {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadComplete(
@@ -81,10 +78,8 @@ class _UpdateDialogState extends State<UpdateDialog> {
Navigator.pop(context);
}
// Open APK for installation
await ApkDownloader.installApk(filePath);
} else {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadFailed();
@@ -116,7 +111,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with icon
Row(
children: [
Container(
@@ -142,7 +136,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
const SizedBox(height: 20),
// Version badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
@@ -165,7 +158,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
const SizedBox(height: 20),
// Download progress (when downloading)
if (_isDownloading) ...[
Container(
padding: const EdgeInsets.all(16),
@@ -209,7 +201,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
),
] else ...[
// Changelog section
Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Container(
@@ -231,7 +222,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
],
const SizedBox(height: 24),
// Action buttons
if (_isDownloading)
SizedBox(
width: double.infinity,
@@ -303,19 +293,16 @@ class _UpdateDialogState extends State<UpdateDialog> {
String _formatChangelog(String changelog) {
var content = changelog;
// Find content after "What's New" header
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
if (whatsNewMatch != null) {
content = content.substring(whatsNewMatch.end);
}
// Cut off at "Downloads" section or horizontal rule
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
if (cutoffMatch != null) {
content = content.substring(0, cutoffMatch.start);
}
// Process line by line for better formatting
final lines = content.split('\n');
final formattedLines = <String>[];
@@ -323,7 +310,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
line = line.trim();
if (line.isEmpty) continue;
// Check if it's a section header
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
if (sectionMatch != null) {
final section = sectionMatch.group(1)?.trim();
@@ -334,7 +320,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
continue;
}
// Check if it's a list item
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
if (listMatch != null) {
var itemText = listMatch.group(1) ?? '';
@@ -344,7 +329,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
continue;
}
// Check if it's a sub-item
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
if (subListMatch != null) {
var itemText = subListMatch.group(1) ?? '';
@@ -401,7 +385,6 @@ class _VersionChip extends StatelessWidget {
}
}
/// Show update dialog
Future<void> showUpdateDialog(
BuildContext context, {
required UpdateInfo updateInfo,