Merge branch 'dev'

# Conflicts:
#	.github/workflows/release.yml
#	README.md
This commit is contained in:
zarzet
2026-01-22 04:01:24 +07:00
64 changed files with 7456 additions and 2242 deletions
+27 -15
View File
@@ -441,20 +441,30 @@ jobs:
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d')
# Use tr -d '\r' to handle CRLF line endings from Windows
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
else
# Convert GitHub Markdown to Telegram Markdown:
# - **text** → *text* (GitHub bold to Telegram bold)
# - ### Header*Header* (headers to bold)
# - Add extra line break before major list items for readability
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code`<code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/\*\*\([^*]*\)\*\*/*\1*/g' | \
sed 's/^### \(.*\)$/*\1*/g' | \
sed 's/^## \(.*\)$/*\1*/g' | \
sed 's/^- \*\*\([^:]*\):\*\*/\n• *\1:*/g' | \
sed 's/^> //' | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
@@ -469,6 +479,8 @@ jobs:
fi
echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
env:
@@ -482,23 +494,23 @@ jobs:
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
# Prepare message with changelog (files uploaded separately)
# Prepare message with changelog (HTML format)
printf '%s\n' \
"*SpotiFLAC Mobile ${VERSION} Released!*" \
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
"" \
"*What's New:*" \
"<b>What's New:</b>" \
"${CHANGELOG}" \
"" \
"[View Release Notes](https://github.com/${{ github.repository }}/releases/tag/${VERSION})" \
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
> /tmp/telegram_message.txt
MESSAGE=$(cat /tmp/telegram_message.txt)
# Send message first
# Send message first (using HTML parse mode)
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
-d text="${MESSAGE}" \
-d parse_mode="Markdown" \
-d parse_mode="HTML" \
-d disable_web_page_preview="true"
# Upload arm64 APK to channel
+101 -1648
View File
File diff suppressed because it is too large Load Diff
+15 -13
View File
@@ -11,16 +11,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflacchat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
</div>
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
@@ -64,6 +54,18 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
## Telegram
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflacchat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ
**Q: Why is my download failing with "Song not found"?**
@@ -78,12 +80,12 @@ A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tr
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**Q: Why is the mobile app so large (~50MB) compared to the PC version (~3MB)?**
A: The mobile app includes FFmpeg libraries for audio processing and format conversion, which adds significant size. The PC version relies on system-installed FFmpeg, keeping the download smaller. We bundle FFmpeg to ensure compatibility across all Android devices without requiring users to install additional software.
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
**Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
@@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"checkDuplicatesBatch" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val tracksJson = call.argument<String>("tracks") ?: "[]"
val response = withContext(Dispatchers.IO) {
Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
}
result.success(response)
}
"preBuildDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.preBuildDuplicateIndex(outputDir)
}
result.success(null)
}
"invalidateDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.invalidateDuplicateIndex(outputDir)
}
result.success(null)
}
"buildFilename" -> {
val template = call.argument<String>("template") ?: ""
val metadata = call.argument<String>("metadata") ?: "{}"
@@ -306,6 +328,43 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
}
result.success(response)
}
"checkAvailabilityByPlatformID" -> {
val platform = call.argument<String>("platform") ?: ""
val entityType = call.argument<String>("entity_type") ?: ""
val entityId = call.argument<String>("entity_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
}
result.success(response)
}
"getSpotifyIDFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getTidalURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getAmazonURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
@@ -468,6 +527,14 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
val response = withContext(Dispatchers.IO) {
Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
}
result.success(response)
}
"removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
@@ -678,6 +745,21 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionHomeFeedJSON(extensionId)
}
result.success(response)
}
"getExtensionBrowseCategories" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
+9 -2
View File
@@ -325,6 +325,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
@@ -339,10 +340,16 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
albumType = "compilation"
}
for _, track := range album.Tracks.Data {
for i, track := range album.Tracks.Data {
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
// Use track position from API, fallback to index+1 if not provided
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
@@ -352,7 +359,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: album.ReleaseDate,
TrackNumber: track.TrackPosition,
TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
+71 -5
View File
@@ -615,10 +615,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
}
result := map[string]interface{}{
"success": true,
"source": lyrics.Source,
"sync_type": lyrics.SyncType,
"lines": lyrics.Lines,
"success": true,
"source": lyrics.Source,
"sync_type": lyrics.SyncType,
"lines": lyrics.Lines,
"instrumental": lyrics.Instrumental,
}
jsonBytes, err := json.Marshal(result)
@@ -630,11 +631,15 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
}
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
// If filePath is provided, ONLY check file - don't fallback to online
// This allows Flutter to distinguish between "from file" vs "from online"
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
// File has no lyrics - return empty, let Flutter call again without filePath
return "", nil
}
client := NewLyricsClient()
@@ -644,6 +649,11 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return "", err
}
// Return special marker for instrumental tracks
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
@@ -1698,6 +1708,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
if trackCover == "" {
trackCover = album.CoverURL
}
// Use track number from extension, fallback to index+1 if not provided
trackNum := track.TrackNumber
if trackNum == 0 {
trackNum = i + 1
}
tracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
@@ -1707,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"duration_ms": track.DurationMS,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"track_number": trackNum,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
@@ -1720,6 +1735,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"artist_id": album.ArtistID,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
@@ -2082,3 +2098,53 @@ func ClearStoreCacheJSON() error {
store.ClearCache()
return nil
}
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
return null;
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("%s returned null", functionName)
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
+23 -21
View File
@@ -719,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
type ExtensionInfo struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
infos := make([]ExtensionInfo, len(extensions))
@@ -796,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
Capabilities: ext.Manifest.Capabilities,
}
}
+19 -18
View File
@@ -107,24 +107,25 @@ type PostProcessingConfig struct {
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
}
// ManifestValidationError represents a validation error in the manifest
+1
View File
@@ -58,6 +58,7 @@ type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
+21
View File
@@ -12,6 +12,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
)
@@ -371,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
})
// Expose getLocalTime - returns device local time info
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now()
_, offsetSeconds := now.Zone()
offsetMinutes := offsetSeconds / 60
return vm.ToValue(map[string]interface{}{
"year": now.Year(),
"month": int(now.Month()),
"day": now.Day(),
"hour": now.Hour(),
"minute": now.Minute(),
"second": now.Second(),
"weekday": int(now.Weekday()),
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
"timezone": now.Location().String(),
"timestamp": now.Unix(),
})
})
}
+47 -11
View File
@@ -240,7 +240,10 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Check cache first
// Normalize artist name - take first artist before comma/semicolon for better matching
primaryArtist := normalizeArtistName(artistName)
// Check cache first (use original artist name for cache key)
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
@@ -251,29 +254,44 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var lyrics *LyricsResponse
var err error
// Try exact match first
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
// Helper to check if lyrics result is valid (has lines OR is instrumental)
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
}
// Try exact match first with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Try with full artist name if different from primary
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Search with duration matching
query := artistName + " " + trackName
// Search with duration matching (use primary artist for search)
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
@@ -281,9 +299,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
@@ -462,6 +480,24 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result)
}
// normalizeArtistName extracts the primary artist from multi-artist strings
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
// e.g., "Artist1; Artist2" -> "Artist1"
func normalizeArtistName(name string) string {
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name
for _, sep := range separators {
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
result = result[:idx]
break
}
}
return strings.TrimSpace(result)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
+8 -2
View File
@@ -1072,13 +1072,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
albumName = req.AlbumName
}
// Use track number from request if available, otherwise from Qobuz API
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
metadata := Metadata{
Title: track.Title,
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC,
@@ -1135,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
ISRC: track.ISRC,
}, nil
+9
View File
@@ -170,6 +170,7 @@ type AlbumInfoMetadata struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
@@ -512,11 +513,19 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string
if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID
}
info := AlbumInfoMetadata{
TotalTracks: data.TotalTracks,
Name: data.Name,
ReleaseDate: data.ReleaseDate,
Artists: joinArtists(data.Artists),
ArtistId: firstArtistId,
Images: albumImage,
}
+16 -8
View File
@@ -331,7 +331,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken()
@@ -630,7 +629,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return
}
@@ -903,7 +902,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
if isDownloadCancelled(itemID) {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
@@ -1346,7 +1345,6 @@ func isLatinScript(s string) bool {
return true
}
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
@@ -1593,15 +1591,25 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
}
// Use track number from request if available, otherwise from Tidal API
actualTrackNumber := req.TrackNumber
actualDiscNumber := req.DiscNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
if actualDiscNumber == 0 {
actualDiscNumber = track.VolumeNumber
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: releaseDate,
TrackNumber: track.TrackNumber,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: track.VolumeNumber,
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
@@ -1659,8 +1667,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
Artist: track.Artist.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
DiscNumber: track.VolumeNumber,
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
}, nil
}
+81
View File
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "checkDuplicatesBatch":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
let tracksJson = args["tracks"] as? String ?? "[]"
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
if let error = error { throw error }
return response
case "preBuildDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendPreBuildDuplicateIndex(outputDir, &error)
if let error = error { throw error }
return nil
case "invalidateDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendInvalidateDuplicateIndex(outputDir)
return nil
case "buildFilename":
let args = call.arguments as! [String: Any]
let template = args["template"] as! String
@@ -249,6 +270,43 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
if let error = error { throw error }
return response
case "checkAvailabilityByPlatformID":
let args = call.arguments as! [String: Any]
let platform = args["platform"] as! String
let entityType = args["entity_type"] as! String
let entityId = args["entity_id"] as! String
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
if let error = error { throw error }
return response
case "getSpotifyIDFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getTidalURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getAmazonURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "preWarmTrackCache":
let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String
@@ -404,6 +462,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let trackJson = args["track"] as? String ?? "{}"
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -605,6 +671,21 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
// Extension Home Feed API
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
if let error = error { throw error }
return response
case "getExtensionBrowseCategories":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.1.3';
static const String buildNumber = '62';
static const String version = '3.2.1';
static const String buildNumber = '64';
static const String fullVersion = '$version+$buildNumber';
+173
View File
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_tr.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -117,6 +118,7 @@ abstract class AppLocalizations {
Locale('pt'),
Locale('pt', 'PT'),
Locale('ru'),
Locale('tr'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
@@ -278,6 +280,12 @@ abstract class AppLocalizations {
/// **'Single track downloads will appear here'**
String get historyNoSinglesSubtitle;
/// Search bar placeholder in history
///
/// In en, this message translates to:
/// **'Search history...'**
String get historySearchHint;
/// Settings screen title
///
/// In en, this message translates to:
@@ -872,6 +880,36 @@ abstract class AppLocalizations {
/// **'Suggest new features for the app'**
String get aboutFeatureRequestSubtitle;
/// Link to Telegram channel
///
/// In en, this message translates to:
/// **'Telegram Channel'**
String get aboutTelegramChannel;
/// Subtitle for Telegram channel
///
/// In en, this message translates to:
/// **'Announcements and updates'**
String get aboutTelegramChannelSubtitle;
/// Link to Telegram chat group
///
/// In en, this message translates to:
/// **'Telegram Community'**
String get aboutTelegramChat;
/// Subtitle for Telegram chat
///
/// In en, this message translates to:
/// **'Chat with other users'**
String get aboutTelegramChatSubtitle;
/// Section for social links
///
/// In en, this message translates to:
/// **'Social'**
String get aboutSocial;
/// Section for support/donation links
///
/// In en, this message translates to:
@@ -2924,6 +2962,24 @@ abstract class AppLocalizations {
/// **'Failed to load lyrics'**
String get trackLyricsLoadFailed;
/// Action - embed lyrics into audio file
///
/// In en, this message translates to:
/// **'Embed Lyrics'**
String get trackEmbedLyrics;
/// Snackbar - lyrics saved to file
///
/// In en, this message translates to:
/// **'Lyrics embedded successfully'**
String get trackLyricsEmbedded;
/// Message when track is instrumental (no lyrics)
///
/// In en, this message translates to:
/// **'Instrumental track'**
String get trackInstrumental;
/// Snackbar - content copied
///
/// In en, this message translates to:
@@ -3650,6 +3706,18 @@ abstract class AppLocalizations {
/// **'Albums/[2005] Album Name/'**
String get albumFolderYearAlbumSubtitle;
/// Album folder option with singles inside artist
///
/// In en, this message translates to:
/// **'Artist / Album + Singles'**
String get albumFolderArtistAlbumSingles;
/// Folder structure example
///
/// In en, this message translates to:
/// **'Artist/Album/ and Artist/Singles/'**
String get albumFolderArtistAlbumSinglesSubtitle;
/// Button - delete selected tracks
///
/// In en, this message translates to:
@@ -3751,6 +3819,108 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Error: {message}'**
String errorGeneric(String message);
/// Button - download artist discography
///
/// In en, this message translates to:
/// **'Download Discography'**
String get discographyDownload;
/// Option - download entire discography
///
/// In en, this message translates to:
/// **'Download All'**
String get discographyDownloadAll;
/// Subtitle showing total tracks and albums
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} releases'**
String discographyDownloadAllSubtitle(int count, int albumCount);
/// Option - download only albums
///
/// In en, this message translates to:
/// **'Albums Only'**
String get discographyAlbumsOnly;
/// Subtitle showing album tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} albums'**
String discographyAlbumsOnlySubtitle(int count, int albumCount);
/// Option - download only singles
///
/// In en, this message translates to:
/// **'Singles & EPs Only'**
String get discographySinglesOnly;
/// Subtitle showing singles tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} singles'**
String discographySinglesOnlySubtitle(int count, int albumCount);
/// Option - manually select albums to download
///
/// In en, this message translates to:
/// **'Select Albums...'**
String get discographySelectAlbums;
/// Subtitle for select albums option
///
/// In en, this message translates to:
/// **'Choose specific albums or singles'**
String get discographySelectAlbumsSubtitle;
/// Progress - fetching album tracks
///
/// In en, this message translates to:
/// **'Fetching tracks...'**
String get discographyFetchingTracks;
/// Progress - fetching specific album
///
/// In en, this message translates to:
/// **'Fetching {current} of {total}...'**
String discographyFetchingAlbum(int current, int total);
/// Selection count badge
///
/// In en, this message translates to:
/// **'{count} selected'**
String discographySelectedCount(int count);
/// Button - download selected albums
///
/// In en, this message translates to:
/// **'Download Selected'**
String get discographyDownloadSelected;
/// Snackbar - tracks added from discography
///
/// In en, this message translates to:
/// **'Added {count} tracks to queue'**
String discographyAddedToQueue(int count);
/// Snackbar - with skipped tracks count
///
/// In en, this message translates to:
/// **'{added} added, {skipped} already downloaded'**
String discographySkippedDownloaded(int added, int skipped);
/// Error - no albums found for artist
///
/// In en, this message translates to:
/// **'No albums available'**
String get discographyNoAlbums;
/// Error - some albums failed to load
///
/// In en, this message translates to:
/// **'Failed to fetch some albums'**
String get discographyFailedToFetch;
}
class _AppLocalizationsDelegate
@@ -3775,6 +3945,7 @@ class _AppLocalizationsDelegate
'nl',
'pt',
'ru',
'tr',
'zh',
].contains(locale.languageCode);
@@ -3837,6 +4008,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsPt();
case 'ru':
return AppLocalizationsRu();
case 'tr':
return AppLocalizationsTr();
case 'zh':
return AppLocalizationsZh();
}
+100
View File
@@ -111,6 +111,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Einzelne Titel-Downloads werden hier angezeigt';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Einstellungen';
@@ -441,6 +444,21 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Schlage neue Funktionen für die App vor';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1613,6 +1631,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2001,6 +2028,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2077,4 +2111,70 @@ class AppLocalizationsDe extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsEn extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,6 +2098,72 @@ class AppLocalizationsEs extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsFr extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsHi extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+100
View File
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Unduhan lagu satuan akan muncul di sini';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Pengaturan';
@@ -434,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Sarankan fitur baru untuk aplikasi';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Dukungan';
@@ -1610,6 +1628,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -2001,6 +2028,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2077,4 +2111,70 @@ class AppLocalizationsId extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Unduh Diskografi';
@override
String get discographyDownloadAll => 'Unduh Semua';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount rilis';
}
@override
String get discographyAlbumsOnly => 'Album Saja';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount album';
}
@override
String get discographySinglesOnly => 'Single & EP Saja';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount single';
}
@override
String get discographySelectAlbums => 'Pilih Album...';
@override
String get discographySelectAlbumsSubtitle =>
'Pilih album atau single tertentu';
@override
String get discographyFetchingTracks => 'Mengambil lagu...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Mengambil $current dari $total...';
}
@override
String discographySelectedCount(int count) {
return '$count dipilih';
}
@override
String get discographyDownloadSelected => 'Unduh yang Dipilih';
@override
String discographyAddedToQueue(int count) {
return 'Menambahkan $count lagu ke antrian';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added ditambahkan, $skipped sudah diunduh';
}
@override
String get discographyNoAlbums => 'Tidak ada album tersedia';
@override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
}
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsJa extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => '設定';
@@ -429,6 +432,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsJa extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsKo extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsNl extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,6 +2098,72 @@ class AppLocalizationsPt extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+100
View File
@@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Здесь будут отображаться загрузки синглов';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Настройки';
@@ -442,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Предложить новые функции для приложения';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Поддержка';
@@ -1634,6 +1652,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
@@ -2029,6 +2056,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderYearAlbumSubtitle =>
'Альбомы/[2005] Название Альбома /';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -2109,4 +2143,70 @@ class AppLocalizationsRu extends AppLocalizations {
String errorGeneric(String message) {
return 'Ошибка: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
File diff suppressed because it is too large Load Diff
+100
View File
@@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,6 +2098,72 @@ class AppLocalizationsZh extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+100 -3
View File
@@ -75,8 +75,10 @@
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
"historyNoSingles": "No single downloads",
"@historyNoSingles": {"description": "Empty state when filtering singles"},
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
"historySearchHint": "Search history...",
"@historySearchHint": {"description": "Search bar placeholder in history"},
"settingsTitle": "Settings",
"@settingsTitle": {"description": "Settings screen title"},
@@ -304,10 +306,20 @@
"@aboutReportIssue": {"description": "Link to report bugs"},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "Feature request",
"@aboutFeatureRequest": {"description": "Link to suggest features"},
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {"description": "Link to Telegram channel"},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {"description": "Link to Telegram chat group"},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"},
"aboutSocial": "Social",
"@aboutSocial": {"description": "Section for social links"},
"aboutSupport": "Support",
"@aboutSupport": {"description": "Section for support/donation links"},
"aboutBuyMeCoffee": "Buy me a coffee",
@@ -1176,6 +1188,12 @@
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
"trackLyricsLoadFailed": "Failed to load lyrics",
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
"trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
"trackDeleteConfirmTitle": "Remove from device?",
@@ -1465,6 +1483,10 @@
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
"downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
@@ -1537,5 +1559,80 @@
"placeholders": {
"message": {"type": "String", "description": "Error message"}
}
}
},
"discographyDownload": "Download Discography",
"@discographyDownload": {"description": "Button - download artist discography"},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {"description": "Option - download entire discography"},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {"description": "Option - download only albums"},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {"description": "Option - download only singles"},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {"description": "Option - manually select albums to download"},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {"description": "Progress - fetching album tracks"},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {"type": "int"}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {"type": "int"}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {"type": "int"},
"skipped": {"type": "int"}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
}
+10 -10
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
"historyTracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}",
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbumes}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
"albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
"artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}",
"artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1108,7 +1108,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -1169,7 +1169,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}",
"snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1916,7 +1916,7 @@
}
}
},
"tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
"tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -2520,7 +2520,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -2559,7 +2559,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
+19 -1
View File
@@ -683,5 +683,23 @@
"recentTypePlaylist": "Playlist",
"recentPlaylistInfo": "Playlist: {name}",
"errorGeneric": "Error: {message}"
"errorGeneric": "Error: {message}",
"discographyDownload": "Unduh Diskografi",
"discographyDownloadAll": "Unduh Semua",
"discographyDownloadAllSubtitle": "{count} lagu dari {albumCount} rilis",
"discographyAlbumsOnly": "Album Saja",
"discographyAlbumsOnlySubtitle": "{count} lagu dari {albumCount} album",
"discographySinglesOnly": "Single & EP Saja",
"discographySinglesOnlySubtitle": "{count} lagu dari {albumCount} single",
"discographySelectAlbums": "Pilih Album...",
"discographySelectAlbumsSubtitle": "Pilih album atau single tertentu",
"discographyFetchingTracks": "Mengambil lagu...",
"discographyFetchingAlbum": "Mengambil {current} dari {total}...",
"discographySelectedCount": "{count} dipilih",
"discographyDownloadSelected": "Unduh yang Dipilih",
"discographyAddedToQueue": "Menambahkan {count} lagu ke antrian",
"discographySkippedDownloaded": "{added} ditambahkan, {skipped} sudah diunduh",
"discographyNoAlbums": "Tidak ada album tersedia",
"discographyFailedToFetch": "Gagal mengambil beberapa album"
}
+6 -6
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
"historyTracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}",
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbuns}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
"albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
"artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}",
"artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
"selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}",
"selectionDeleteTracks": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1916,7 +1916,7 @@
}
}
},
"tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
"tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
+7
View File
@@ -0,0 +1,7 @@
{
"@@locale": "tr",
"@@last_modified": "2026-01-21",
"appName": "SpotiFLAC",
"@appName": {"description": "App name - DO NOT TRANSLATE"}
}
+151 -126
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('DownloadQueue');
@@ -130,15 +131,36 @@ class DownloadHistoryItem {
class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds;
final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _byIsrc;
DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => item.spotifyId!)
.toSet();
.toSet(),
_bySpotifyId = Map.fromEntries(
items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => MapEntry(item.spotifyId!, item)),
),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
);
/// O(1) check if spotify_id exists
bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId);
/// O(1) lookup by spotify_id
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
_bySpotifyId[spotifyId];
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) =>
_byIsrc[isrc];
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
return DownloadHistoryState(items: items ?? this.items);
@@ -146,130 +168,66 @@ class DownloadHistoryState {
}
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const _storageKey = 'download_history';
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
@override
DownloadHistoryState build() {
_loadFromStorageSync();
_loadFromDatabaseSync();
return DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromStorageSync() {
void _loadFromDatabaseSync() {
if (_isLoaded) return;
_isLoaded = true;
Future.microtask(() async {
await _loadFromStorage();
_isLoaded = true;
await _loadFromDatabase();
});
}
Future<void> _loadFromStorage() async {
Future<void> _loadFromDatabase() async {
try {
final prefs = await _prefs;
final jsonStr = prefs.getString(_storageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
.toList();
final deduplicatedItems = _deduplicateHistory(items);
state = state.copyWith(items: deduplicatedItems);
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
if (deduplicatedItems.length < items.length) {
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
await _saveToStorage();
}
} else {
_historyLog.d('No history found in storage');
}
} catch (e) {
_historyLog.e('Failed to load history: $e');
}
}
/// Keeps the most recent entry (first occurrence since list is sorted by date desc)
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
final seen = <String, int>{}; // key -> index of first occurrence
final result = <DownloadHistoryItem>[];
for (int i = 0; i < items.length; i++) {
final item = items[i];
String? key;
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
if (item.spotifyId!.startsWith('deezer:')) {
key = 'deezer:${item.spotifyId!.substring(7)}';
} else {
key = 'spotify:${item.spotifyId}';
}
} else if (item.isrc != null && item.isrc!.isNotEmpty) {
key = 'isrc:${item.isrc}';
final migrated = await _db.migrateFromSharedPreferences();
if (migrated) {
_historyLog.i('Migrated history from SharedPreferences to SQLite');
}
if (key != null) {
if (!seen.containsKey(key)) {
seen[key] = result.length;
result.add(item);
} else {
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
// Migrate iOS paths if container UUID changed after app update
if (Platform.isIOS) {
final pathsMigrated = await _db.migrateIosContainerPaths();
if (pathsMigrated) {
_historyLog.i('Migrated iOS container paths after app update');
}
} else {
result.add(item);
}
}
return result;
}
Future<void> _saveToStorage() async {
try {
final prefs = await _prefs;
final jsonList = state.items.map((e) => e.toJson()).toList();
await prefs.setString(_storageKey, jsonEncode(jsonList));
_historyLog.d('Saved ${state.items.length} items to storage');
} catch (e) {
_historyLog.e('Failed to save history: $e');
final jsonList = await _db.getAll();
final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e))
.toList();
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from SQLite database');
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
}
}
Future<void> reloadFromStorage() async {
await _loadFromStorage();
await _loadFromDatabase();
}
void addToHistory(DownloadHistoryItem item) {
final existingIndex = state.items.indexWhere((existing) {
if (item.spotifyId != null &&
item.spotifyId!.isNotEmpty &&
existing.spotifyId == item.spotifyId) {
return true;
}
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
final itemDeezerId = item.spotifyId!.substring(7);
final existingDeezerId = existing.spotifyId!.substring(7);
if (itemDeezerId == existingDeezerId) {
return true;
}
}
if (item.isrc != null &&
item.isrc!.isNotEmpty &&
existing.isrc == item.isrc) {
return true;
}
return false;
});
DownloadHistoryItem? existing;
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
existing = state.getBySpotifyId(item.spotifyId!);
}
if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) {
existing = state.getByIsrc(item.isrc!);
}
if (existingIndex >= 0) {
final updatedItems = [...state.items];
updatedItems[existingIndex] = item;
updatedItems.removeAt(existingIndex);
if (existing != null) {
final updatedItems = state.items.where((i) => i.id != existing!.id).toList();
updatedItems.insert(0, item);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
@@ -277,31 +235,60 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
}
_saveToStorage();
_db.upsert(item.toJson()).catchError((e) {
_historyLog.e('Failed to save to database: $e');
});
}
void removeFromHistory(String id) {
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
);
_saveToStorage();
_db.deleteById(id).catchError((e) {
_historyLog.e('Failed to delete from database: $e');
});
}
void removeBySpotifyId(String spotifyId) {
state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
);
_saveToStorage();
_db.deleteBySpotifyId(spotifyId).catchError((e) {
_historyLog.e('Failed to delete from database: $e');
});
_historyLog.d('Removed item with spotifyId: $spotifyId');
}
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
return state.getBySpotifyId(spotifyId);
}
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc);
}
/// Async version with database lookup (for cases where in-memory might be stale)
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
final inMemory = state.getBySpotifyId(spotifyId);
if (inMemory != null) return inMemory;
final json = await _db.getBySpotifyId(spotifyId);
if (json == null) return null;
return DownloadHistoryItem.fromJson(json);
}
void clearHistory() {
state = DownloadHistoryState();
_saveToStorage();
_db.clearAll().catchError((e) {
_historyLog.e('Failed to clear database: $e');
});
}
/// Get database stats for debugging
Future<int> getDatabaseCount() async {
return await _db.getCount();
}
}
@@ -488,10 +475,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final currentItems = state.items;
final itemsById = <String, DownloadItem>{};
final itemIndexById = <String, int>{};
int queuedCount = 0;
int downloadingCount = 0;
DownloadItem? firstDownloading;
for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i];
itemsById[item.id] = item;
itemIndexById[item.id] = i;
if (item.status == DownloadStatus.downloading) {
downloadingCount++;
firstDownloading ??= item;
}
if (item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading) {
queuedCount++;
}
}
final progressUpdates = <String, _ProgressUpdate>{};
@@ -613,15 +611,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
final downloadingItems = state.items
.where((i) => i.status == DownloadStatus.downloading)
.toList();
if (downloadingItems.isNotEmpty) {
final trackName = downloadingItems.length == 1
? downloadingItems.first.track.name
: '${downloadingItems.length} downloads';
final artistName = downloadingItems.length == 1
? downloadingItems.first.track.artistName
if (downloadingCount > 0 && firstDownloading != null) {
final trackName = downloadingCount == 1
? firstDownloading.track.name
: '$downloadingCount downloads';
final artistName = downloadingCount == 1
? firstDownloading.track.artistName
: 'Downloading...';
int notifProgress = bytesReceived;
@@ -643,11 +638,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress(
trackName: downloadingItems.first.track.name,
artistName: downloadingItems.first.track.artistName,
trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName,
progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1,
queueCount: state.queuedCount,
queueCount: queuedCount,
).catchError((_) {});
}
}
@@ -725,14 +720,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (separateSingles) {
final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist);
// New option: Singles folder inside Artist folder
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Artist Singles folder');
return singlesPath;
} else {
final albumName = _sanitizeFolderName(track.albumName);
final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
await _ensureDirExists(albumPath, label: 'Artist Album folder');
return albumPath;
}
}
// Existing behavior: Separate Albums/ and Singles/ at root
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder');
return singlesPath;
} else {
final albumName = _sanitizeFolderName(track.albumName);
final artistName = _sanitizeFolderName(albumArtist);
final year = _extractYear(track.releaseDate);
String albumPath;
@@ -790,7 +800,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String _sanitizeFolderName(String name) {
return name
.replaceAll(_invalidFolderChars, '_')
.replaceAll(_trailingDotsRegex, '') // Remove trailing dots
.replaceAll(_trailingDotsRegex, '')
.trim();
}
@@ -1067,8 +1077,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273';
const spotifySizeMax = 'ab67616d000082c1';
var result = coverUrl;
@@ -1182,10 +1192,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
durationMs: durationMs,
);
if (lrcContent.isNotEmpty) {
// Skip instrumental tracks (no lyrics to embed)
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
} else if (lrcContent == '[instrumental:true]') {
_log.d('Track is instrumental, skipping lyrics embedding');
}
} catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e');
@@ -1655,7 +1668,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final quality = item.qualityOverride ?? state.audioQuality;
// Fetch extended metadata (genre, label) from Deezer if available
// Fetch extended metadata (genre, label) from Deezer if available
String? genre;
String? label;
@@ -1667,6 +1680,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
deezerTrackId = trackToDownload.availability!.deezerId;
}
if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) {
try {
_log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
final deezerResult = await PlatformBridge.searchDeezerByISRC(trackToDownload.isrc!);
if (deezerResult['success'] == true && deezerResult['track_id'] != null) {
deezerTrackId = deezerResult['track_id'].toString();
_log.d('Found Deezer track ID via ISRC: $deezerTrackId');
}
} catch (e) {
_log.w('Failed to search Deezer by ISRC: $e');
}
}
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
try {
final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId);
@@ -1758,9 +1784,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id, // Pass item ID for progress tracking
durationMs:
trackToDownload.duration, // Duration in ms for verification
itemId: item.id,
durationMs: trackToDownload.duration,
);
}
@@ -1800,7 +1825,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final actualBitDepth = result['actual_bit_depth'] as int?;
final actualSampleRate = result['actual_sample_rate'] as int?;
String actualQuality = quality; // Default to requested quality
String actualQuality = quality;
if (actualBitDepth != null && actualBitDepth > 0) {
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
+265
View File
@@ -0,0 +1,265 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('ExploreProvider');
/// Represents an item in a Spotify home section
class ExploreItem {
final String id;
final String uri;
final String type; // track, album, playlist, artist, station
final String name;
final String artists;
final String? description;
final String? coverUrl;
final String? providerId;
final String? albumId;
final String? albumName;
final int durationMs;
const ExploreItem({
required this.id,
required this.uri,
required this.type,
required this.name,
required this.artists,
this.description,
this.coverUrl,
this.providerId,
this.albumId,
this.albumName,
this.durationMs = 0,
});
factory ExploreItem.fromJson(Map<String, dynamic> json) {
return ExploreItem(
id: json['id'] as String? ?? '',
uri: json['uri'] as String? ?? '',
type: json['type'] as String? ?? 'track',
name: json['name'] as String? ?? '',
artists: json['artists'] as String? ?? '',
description: json['description'] as String?,
coverUrl: json['cover_url'] as String?,
providerId: json['provider_id'] as String?,
albumId: json['album_id'] as String?,
albumName: json['album_name'] as String?,
durationMs: json['duration_ms'] as int? ?? 0,
);
}
}
/// Represents a section in Spotify home feed
class ExploreSection {
final String uri;
final String title;
final List<ExploreItem> items;
final bool isYTMusicQuickPicks;
const ExploreSection({
required this.uri,
required this.title,
required this.items,
this.isYTMusicQuickPicks = false,
});
factory ExploreSection.fromJson(Map<String, dynamic> json) {
final itemsList = json['items'] as List<dynamic>? ?? [];
final items = itemsList
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
.toList();
final isQuickPicks = _isYTMusicQuickPicksItems(items);
return ExploreSection(
uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '',
items: items,
isYTMusicQuickPicks: isQuickPicks,
);
}
}
/// State for explore/home feed
class ExploreState {
final bool isLoading;
final String? error;
final String? greeting;
final List<ExploreSection> sections;
final DateTime? lastFetched;
const ExploreState({
this.isLoading = false,
this.error,
this.greeting,
this.sections = const [],
this.lastFetched,
});
bool get hasContent => sections.isNotEmpty;
ExploreState copyWith({
bool? isLoading,
String? error,
String? greeting,
List<ExploreSection>? sections,
DateTime? lastFetched,
}) {
return ExploreState(
isLoading: isLoading ?? this.isLoading,
error: error,
greeting: greeting ?? this.greeting,
sections: sections ?? this.sections,
lastFetched: lastFetched ?? this.lastFetched,
);
}
}
/// Calculate greeting based on local device time
String _getLocalGreeting() {
final hour = DateTime.now().hour;
if (hour >= 5 && hour < 12) {
return 'Good morning';
} else if (hour >= 12 && hour < 17) {
return 'Good afternoon';
} else if (hour >= 17 && hour < 21) {
return 'Good evening';
} else {
return 'Good night';
}
}
bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
if (items.isEmpty) return false;
if (items.first.providerId != 'ytmusic-spotiflac') return false;
for (final item in items) {
if (item.type != 'track') {
return false;
}
}
return true;
}
/// Provider for explore/home feed state
class ExploreNotifier extends Notifier<ExploreState> {
@override
ExploreState build() {
return const ExploreState();
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// Don't refetch if we have data and it's less than 5 minutes old
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
}
state = state.copyWith(isLoading: true, error: null);
try {
// Find any extension with homeFeed capability
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
// Look for extensions with homeFeed capability (prefer spotify-web)
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
break;
}
}
}
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
isLoading: false,
error: 'No extension with home feed support enabled',
);
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
if (result == null) {
state = state.copyWith(
isLoading: false,
error: 'Failed to fetch home feed',
);
return;
}
final success = result['success'] as bool? ?? false;
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
isLoading: false,
error: error,
);
return;
}
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.toList();
_log.i('Fetched ${sections.length} sections');
// Debug: log first section items
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
}
// Always use local device time for greeting to avoid timezone issues
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
final localGreeting = _getLocalGreeting();
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
state = ExploreState(
isLoading: false,
greeting: localGreeting,
sections: sections,
lastFetched: DateTime.now(),
);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Clear cached data
void clear() {
state = const ExploreState();
}
/// Refresh home feed
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+7
View File
@@ -26,6 +26,7 @@ class Extension {
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({
required this.id,
@@ -48,6 +49,7 @@ class Extension {
this.urlHandler,
this.trackMatching,
this.postProcessing,
this.capabilities = const {},
});
factory Extension.fromJson(Map<String, dynamic> json) {
@@ -84,6 +86,7 @@ class Extension {
postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
: null,
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
);
}
@@ -108,6 +111,7 @@ class Extension {
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
Map<String, dynamic>? capabilities,
}) {
return Extension(
id: id ?? this.id,
@@ -130,6 +134,7 @@ class Extension {
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
capabilities: capabilities ?? this.capabilities,
);
}
@@ -137,6 +142,8 @@ class Extension {
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
}
class SearchBehavior {
+5 -3
View File
@@ -100,6 +100,8 @@ class RecentAccessState {
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override
RecentAccessState build() {
_loadHistory();
@@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
@@ -132,13 +134,13 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
Future<void> _saveHiddenDownloads() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
}
+4 -2
View File
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override
AppSettings build() {
_loadSettings();
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
@@ -46,7 +48,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
}
+3 -2
View File
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
final RegExp _leadingVersionPrefix = RegExp(r'^v');
/// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
+4 -2
View File
@@ -10,6 +10,8 @@ final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
/// Notifier for managing theme settings with persistence
class ThemeNotifier extends Notifier<ThemeSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override
ThemeSettings build() {
// Load settings asynchronously on first access
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
/// Load theme settings from SharedPreferences
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey);
@@ -40,7 +42,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
/// Save current settings to SharedPreferences
Future<void> _saveToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue);
+141 -39
View File
@@ -2,7 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -12,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -43,6 +45,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null
final String? extensionId; // If from extension
final String? artistId; // Artist ID for navigation
final String? artistName; // Artist name for navigation
const AlbumScreen({
super.key,
@@ -50,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
required this.albumName,
this.coverUrl,
this.tracks,
this.extensionId,
this.artistId,
this.artistName,
});
@override
@@ -62,6 +70,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
String? _artistId;
final ScrollController _scrollController = ScrollController();
@override
@@ -78,10 +87,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
);
});
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
_artistId = widget.artistId; // Use provided artist ID if available
if (_tracks == null) {
_fetchTracks();
}
@@ -103,25 +114,33 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
Future<void> _extractDominantColor() async {
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
Future<void> _fetchTracks() async {
String _formatReleaseDate(String date) {
// Handle formats: "2024-01-15", "2024-01", "2024"
if (date.length >= 10) {
// Full date: 2024-01-15
final parts = date.substring(0, 10).split('-');
if (parts.length == 3) {
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
}
} else if (date.length >= 7) {
// Month: 2024-01
final parts = date.split('-');
if (parts.length >= 2) {
return '${parts[1]}/${parts[0]}'; // MM/YYYY
}
}
return date; // Year only or unknown format
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
Map<String, dynamic> metadata;
@@ -137,11 +156,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Extract artist ID from album_info if available
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
@@ -310,9 +334,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter(
child: Padding(
@@ -332,32 +357,59 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
GestureDetector(
onTap: () => _navigateToArtist(context, artistName),
child: Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
],
@@ -436,10 +488,51 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
void _navigateToArtist(BuildContext context, String artistName) {
// Use stored artist ID if available, otherwise use a placeholder
final artistId = _artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
// Don't navigate if artist ID is unknown
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
);
return;
}
// If from extension, use ExtensionArtistScreen
if (widget.extensionId != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: widget.extensionId!,
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
@@ -534,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
leading: SizedBox(
width: 32,
child: Center(
child: Text(
'${track.trackNumber ?? 0}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
+675 -7
View File
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
@@ -14,6 +15,7 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Simple in-memory cache for artist data
class _ArtistCache {
@@ -100,6 +102,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
// Selection mode state
bool _isSelectionMode = false;
final Set<String> _selectedAlbumIds = {};
bool _isFetchingDiscography = false;
@override
void initState() {
super.initState();
@@ -278,11 +285,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
body: CustomScrollView(
final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty;
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildHeader(context, colorScheme),
_buildHeader(context, colorScheme, albums: albums, hasDiscography: hasDiscography),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
@@ -303,13 +321,444 @@ return Scaffold(
if (compilations.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
// Add padding at bottom for selection bar
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
],
),
// Selection action bar
if (_isSelectionMode)
_buildSelectionBar(context, colorScheme, albums),
],
),
),
);
}
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
void _exitSelectionMode() {
HapticFeedback.lightImpact();
setState(() {
_isSelectionMode = false;
_selectedAlbumIds.clear();
});
}
void _enterSelectionMode(String albumId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedAlbumIds.add(albumId);
});
}
void _toggleAlbumSelection(String albumId) {
HapticFeedback.selectionClick();
setState(() {
if (_selectedAlbumIds.contains(albumId)) {
_selectedAlbumIds.remove(albumId);
if (_selectedAlbumIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedAlbumIds.add(albumId);
}
});
}
void _selectAll(List<ArtistAlbum> albums) {
setState(() {
_selectedAlbumIds.addAll(albums.map((a) => a.id));
});
}
void _deselectAll() {
setState(() {
_selectedAlbumIds.clear();
});
}
Widget _buildSelectionBar(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> allAlbums) {
final allSelected = _selectedAlbumIds.length == allAlbums.length;
final selectedCount = _selectedAlbumIds.length;
final selectedAlbums = allAlbums.where((a) => _selectedAlbumIds.contains(a.id)).toList();
final totalTracks = selectedAlbums.fold<int>(0, (sum, a) => sum + a.totalTracks);
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Close button
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
// Selection info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.discographySelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// Select all / Deselect button
TextButton(
onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums),
child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
),
const SizedBox(width: 8),
// Download button
FilledButton.icon(
onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null,
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownloadSelected),
),
],
),
),
),
),
);
}
void _showDiscographyOptions(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> albums) {
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
final albumTracks = albumsOnly.fold<int>(0, (sum, a) => sum + a.totalTracks);
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
// Title
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
children: [
Icon(Icons.download, color: colorScheme.primary),
const SizedBox(width: 12),
Text(
context.l10n.discographyDownload,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const Divider(height: 1),
// Options
if (albums.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.library_music,
title: context.l10n.discographyDownloadAll,
subtitle: context.l10n.discographyDownloadAllSubtitle(totalTracks, albums.length),
onTap: () {
Navigator.pop(context);
_downloadAlbums(context, albums);
},
),
if (albumsOnly.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.album,
title: context.l10n.discographyAlbumsOnly,
subtitle: context.l10n.discographyAlbumsOnlySubtitle(albumTracks, albumsOnly.length),
onTap: () {
Navigator.pop(context);
_downloadAlbums(context, albumsOnly);
},
),
if (singles.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.music_note,
title: context.l10n.discographySinglesOnly,
subtitle: context.l10n.discographySinglesOnlySubtitle(singleTracks, singles.length),
onTap: () {
Navigator.pop(context);
_downloadAlbums(context, singles);
},
),
_DiscographyOptionTile(
icon: Icons.checklist,
title: context.l10n.discographySelectAlbums,
subtitle: context.l10n.discographySelectAlbumsSubtitle,
onTap: () {
Navigator.pop(context);
_enterSelectionMode(albums.first.id);
},
),
const SizedBox(height: 8),
],
),
),
),
);
}
Future<void> _downloadAlbums(BuildContext context, List<ArtistAlbum> albums) async {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
onSelect: (quality, service) {
_fetchAndQueueAlbums(albums, service, quality);
},
);
} else {
_fetchAndQueueAlbums(albums, settings.defaultService, null);
}
}
Future<void> _downloadSelectedAlbums(BuildContext context, List<ArtistAlbum> albums) async {
_exitSelectionMode();
await _downloadAlbums(context, albums);
}
Future<void> _fetchAndQueueAlbums(
List<ArtistAlbum> albums,
String service,
String? qualityOverride,
) async {
if (_isFetchingDiscography) return;
setState(() => _isFetchingDiscography = true);
// Show progress dialog
if (!mounted) {
setState(() => _isFetchingDiscography = false);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog(
totalAlbums: albums.length,
onCancel: () {
setState(() => _isFetchingDiscography = false);
Navigator.pop(ctx);
},
),
);
final allTracks = <Track>[];
int fetchedCount = 0;
int failedCount = 0;
// Fetch tracks from each album
for (final album in albums) {
if (!_isFetchingDiscography) break; // Cancelled
try {
final tracks = await _fetchAlbumTracks(album);
allTracks.addAll(tracks);
} catch (e) {
failedCount++;
}
fetchedCount++;
// Update progress dialog
if (mounted) {
_FetchingProgressDialog.updateProgress(context, fetchedCount, albums.length);
}
}
setState(() => _isFetchingDiscography = false);
// Close progress dialog
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
// Show warning if some albums failed
if (failedCount > 0 && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
);
}
if (allTracks.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.discographyNoAlbums)),
);
}
return;
}
// Check which tracks are already downloaded
final historyState = ref.read(downloadHistoryProvider);
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in allTracks) {
final isDownloaded = historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null);
if (!isDownloaded) {
tracksToQueue.add(track);
} else {
skippedCount++;
}
}
if (tracksToQueue.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.discographySkippedDownloaded(0, skippedCount)),
),
);
}
return;
}
// Add to queue
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: qualityOverride,
);
// Show success message
if (mounted) {
final message = skippedCount > 0
? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount)
: context.l10n.discographyAddedToQueue(tracksToQueue.length);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: SnackBarAction(
label: context.l10n.snackbarViewQueue,
onPressed: () {
// Navigate to queue tab (index 1)
// This will be handled by the navigation system
},
),
),
);
}
}
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
if (album.providerId != null && album.providerId!.isNotEmpty) {
// Extension album
final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
} else if (album.id.startsWith('deezer:')) {
// Deezer album
final deezerId = album.id.replaceFirst('deezer:', '');
final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId);
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album)).toList();
}
} else {
// Spotify album
final url = 'https://open.spotify.com/album/${album.id}';
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
// Fallback to direct Spotify metadata
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
}
return [];
}
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
int durationMs = 0;
final durationValue = data['duration'];
if (durationValue is int) {
durationMs = durationValue * 1000; // Deezer returns seconds
} else if (durationValue is double) {
durationMs = (durationValue * 1000).toInt();
}
return Track(
id: 'deezer:${data['id']}',
name: (data['title'] ?? data['name'] ?? '').toString(),
artistName: (data['artist']?['name'] ?? data['artist'] ?? widget.artistName).toString(),
albumName: album.name,
albumArtist: widget.artistName,
coverUrl: album.coverUrl,
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_position'] as int? ?? data['track_number'] as int?,
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
releaseDate: album.releaseDate,
albumType: album.albumType,
);
}
Widget _buildHeader(BuildContext context, ColorScheme colorScheme, {
required List<ArtistAlbum> albums,
required bool hasDiscography,
}) {
String? imageUrl = _headerImageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.headerImageUrl;
@@ -330,7 +779,7 @@ return Scaffold(
}
return SliverAppBar(
expandedHeight: 380,
expandedHeight: hasDiscography ? 420 : 380,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -429,6 +878,26 @@ if (hasValidImage)
),
),
],
// Download Discography button
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(height: 12),
SizedBox(
height: 40,
child: FilledButton.icon(
onPressed: () => _showDiscographyOptions(context, colorScheme, albums),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownload),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
),
],
],
),
),
@@ -739,14 +1208,29 @@ if (hasValidImage)
}
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
final isSelected = _selectedAlbumIds.contains(album.id);
return GestureDetector(
onTap: () => _navigateToAlbum(album),
onTap: () {
if (_isSelectionMode) {
_toggleAlbumSelection(album.id);
} else {
_navigateToAlbum(album);
}
},
onLongPress: () {
if (!_isSelectionMode) {
_enterSelectionMode(album.id);
}
},
child: Container(
width: 140,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
@@ -775,6 +1259,50 @@ if (hasValidImage)
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
),
),
// Selection overlay
if (_isSelectionMode)
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
border: isSelected
? Border.all(color: colorScheme.primary, width: 3)
: null,
),
),
),
// Checkbox
if (_isSelectionMode)
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 18)
: null,
),
),
],
),
const SizedBox(height: 8),
Text(
@@ -886,3 +1414,143 @@ if (hasValidImage)
);
}
}
/// Option tile for discography download bottom sheet
class _DiscographyOptionTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _DiscographyOptionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 24),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(
subtitle,
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: onTap,
);
}
}
/// Progress dialog shown while fetching album tracks
class _FetchingProgressDialog extends StatefulWidget {
final int totalAlbums;
final VoidCallback onCancel;
const _FetchingProgressDialog({
required this.totalAlbums,
required this.onCancel,
});
// Static method to update progress from outside
static void updateProgress(BuildContext context, int current, int total) {
final state = context.findAncestorStateOfType<_FetchingProgressDialogState>();
state?._updateProgress(current, total);
}
@override
State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState();
}
class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
int _current = 0;
int _total = 0;
@override
void initState() {
super.initState();
_total = widget.totalAlbums;
}
void _updateProgress(int current, int total) {
if (mounted) {
setState(() {
_current = current;
_total = total;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final progress = _total > 0 ? _current / _total : 0.0;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
SizedBox(
width: 64,
height: 64,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 4,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.library_music, color: colorScheme.primary, size: 24),
],
),
),
const SizedBox(height: 20),
Text(
context.l10n.discographyFetchingTracks,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
context.l10n.discographyFetchingAlbum(_current, _total),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress > 0 ? progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
minHeight: 6,
),
),
],
),
actions: [
TextButton(
onPressed: widget.onCancel,
child: Text(context.l10n.dialogCancel),
),
],
);
}
}
+19 -19
View File
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -59,36 +59,36 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
// Only use network images for palette extraction
final isNetworkUrl = widget.coverUrl!.startsWith('http://') ||
widget.coverUrl!.startsWith('https://');
if (!isNetworkUrl) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
// Check cache first (instant)
final cached = PaletteService.instance.getCached(widget.coverUrl);
if (cached != null) {
if (mounted && cached != _dominantColor) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
_dominantColor = cached;
});
}
} catch (_) {
return;
}
// Extract in isolate (non-blocking)
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
final itemKey = '${item.albumName}|$itemArtist';
final albumKey = '${widget.albumName}|${widget.artistName}';
// Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey;
}).toList()
..sort((a, b) {
+758 -82
View File
File diff suppressed because it is too large Load Diff
+10 -17
View File
@@ -2,7 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -55,19 +55,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
@@ -225,12 +215,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
],
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
),
+423 -119
View File
@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -19,6 +21,7 @@ class _GroupedAlbum {
final String? coverUrl;
final List<DownloadHistoryItem> tracks;
final DateTime latestDownload;
final String searchKey;
_GroupedAlbum({
required this.albumName,
@@ -26,7 +29,7 @@ class _GroupedAlbum {
this.coverUrl,
required this.tracks,
required this.latestDownload,
});
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
String get key => '$albumName|$artistName';
}
@@ -45,6 +48,42 @@ class _HistoryStats {
});
}
Map<String, List<String>> _filterHistoryInIsolate(
Map<String, Object> payload,
) {
final entries = (payload['entries'] as List).cast<List>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
final query = (payload['query'] as String?) ?? '';
final allIds = <String>[];
final albumIds = <String>[];
final singleIds = <String>[];
for (final entry in entries) {
final id = entry[0] as String;
final albumKey = entry[1] as String;
final searchKey = entry[2] as String;
if (query.isNotEmpty && !searchKey.contains(query)) {
continue;
}
allIds.add(id);
final count = albumCounts[albumKey] ?? 0;
if (count > 1) {
albumIds.add(id);
} else if (count == 1) {
singleIds.add(id);
}
}
return {
'all': allIds,
'albums': albumIds,
'singles': singleIds,
};
}
class QueueTab extends ConsumerStatefulWidget {
final PageController? parentPageController;
final int parentPageIndex;
@@ -73,6 +112,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final List<String> _filterModes = ['all', 'albums', 'singles'];
bool _isPageControllerInitialized = false;
// Search functionality
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String _searchQuery = '';
Timer? _searchDebounce;
List<DownloadHistoryItem>? _historyItemsCache;
_HistoryStats? _historyStatsCache;
final Map<String, String> _searchIndexCache = {};
Map<String, DownloadHistoryItem> _historyItemsById = {};
List<List<String>> _historyFilterEntries = const [];
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
List<DownloadHistoryItem>? _filterItemsCache;
String _filterQueryCache = '';
bool _filterRefreshScheduled = false;
bool _isFilteringHistory = false;
int _filterRequestId = 0;
static const int _filterIsolateThreshold = 800;
@override
@@ -88,12 +145,178 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterPageController = PageController(initialPage: initialPage);
}
@override
@override
void dispose() {
_filterPageController?.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_searchDebounce?.cancel();
final normalized = value.trim().toLowerCase();
_searchDebounce = Timer(const Duration(milliseconds: 180), () {
if (!mounted || _searchQuery == normalized) return;
setState(() => _searchQuery = normalized);
_requestFilterRefresh();
});
}
void _clearSearch() {
_searchDebounce?.cancel();
if (_searchQuery.isEmpty) return;
setState(() => _searchQuery = '');
_requestFilterRefresh();
}
void _ensureHistoryCaches(List<DownloadHistoryItem> items) {
if (identical(items, _historyItemsCache)) return;
_historyItemsCache = items;
_historyStatsCache = _buildHistoryStats(items);
_searchIndexCache
..clear()
..addEntries(
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
);
_historyItemsById = {for (final item in items) item.id: item};
_historyFilterEntries = List<List<String>>.generate(
items.length,
(index) {
final item = items[index];
final searchKey =
_searchIndexCache[item.id] ?? _buildSearchKey(item);
final albumKey =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return [item.id, albumKey, searchKey];
},
growable: false,
);
_requestFilterRefresh();
}
String _buildSearchKey(DownloadHistoryItem item) {
return '${item.trackName} ${item.artistName} ${item.albumName}'
.toLowerCase();
}
bool _isFilterCacheValid(List<DownloadHistoryItem> items, String query) {
return identical(items, _filterItemsCache) && query == _filterQueryCache;
}
void _requestFilterRefresh() {
if (_filterRefreshScheduled) return;
_filterRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_filterRefreshScheduled = false;
if (!mounted) return;
_scheduleHistoryFilterUpdate();
});
}
void _scheduleHistoryFilterUpdate() {
final items = _historyItemsCache;
if (items == null) return;
final query = _searchQuery;
if (_isFilterCacheValid(items, query)) return;
final albumCounts =
_historyStatsCache?.albumCounts ?? const <String, int>{};
if (items.isEmpty) {
setState(() {
_filteredHistoryCache = const {};
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
return;
}
if (items.length <= _filterIsolateThreshold) {
final filteredAll =
_filterHistoryItems(items, 'all', albumCounts, query);
final filteredAlbums =
_filterHistoryItems(items, 'albums', albumCounts, query);
final filteredSingles =
_filterHistoryItems(items, 'singles', albumCounts, query);
setState(() {
_filteredHistoryCache = {
'all': filteredAll,
'albums': filteredAlbums,
'singles': filteredSingles,
};
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
return;
}
if (!_isFilteringHistory) {
setState(() => _isFilteringHistory = true);
}
final requestId = ++_filterRequestId;
final payload = <String, Object>{
'entries': _historyFilterEntries,
'albumCounts': albumCounts,
'query': query,
};
compute(_filterHistoryInIsolate, payload).then((result) {
if (!mounted || requestId != _filterRequestId) return;
final itemsById = _historyItemsById;
final filtered = <String, List<DownloadHistoryItem>>{};
for (final entry in result.entries) {
filtered[entry.key] = entry.value
.map((id) => itemsById[id])
.whereType<DownloadHistoryItem>()
.toList(growable: false);
}
setState(() {
_filteredHistoryCache = filtered;
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
});
}
List<DownloadHistoryItem> _resolveHistoryItems({
required String filterMode,
required List<DownloadHistoryItem> allHistoryItems,
required Map<String, int> albumCounts,
}) {
final query = _searchQuery;
if (_isFilterCacheValid(allHistoryItems, query)) {
final cached = _filteredHistoryCache[filterMode];
if (cached != null) return cached;
}
if (allHistoryItems.isEmpty) return const [];
if (query.isEmpty && filterMode == 'all') return allHistoryItems;
if (allHistoryItems.length <= _filterIsolateThreshold) {
return _filterHistoryItems(
allHistoryItems,
filterMode,
albumCounts,
query,
);
}
return const [];
}
bool _shouldShowFilteringIndicator({
required List<DownloadHistoryItem> allHistoryItems,
required String filterMode,
}) {
if (allHistoryItems.isEmpty) return false;
if (_searchQuery.isEmpty && filterMode == 'all') return false;
if (allHistoryItems.length <= _filterIsolateThreshold) return false;
return !_isFilterCacheValid(allHistoryItems, _searchQuery) ||
_isFilteringHistory;
}
void _onFilterPageChanged(int index) {
final filterMode = _filterModes[index];
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
@@ -274,7 +497,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
_precacheCover(historyItem.coverUrl);
_precacheCover(historyItem.coverUrl);
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
@@ -285,11 +509,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
).then((_) => _searchFocusNode.unfocus());
}
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
@@ -300,46 +525,63 @@ class _QueueTabState extends ConsumerState<QueueTab> {
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
).then((_) => _searchFocusNode.unfocus());
}
List<DownloadHistoryItem> _filterHistoryItems(
List<DownloadHistoryItem> _filterHistoryItems(
List<DownloadHistoryItem> items,
String filterMode,
Map<String, int> albumCounts,
) {
if (filterMode == 'all') return items;
Map<String, int> albumCounts, [
String searchQuery = '',
]) {
// First apply search filter
var filteredItems = items;
if (searchQuery.isNotEmpty) {
final query = searchQuery;
filteredItems = items.where((item) {
final searchKey =
_searchIndexCache[item.id] ?? _buildSearchKey(item);
if (!_searchIndexCache.containsKey(item.id)) {
_searchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query);
}).toList();
}
switch (filterMode) {
// Then apply filter mode
if (filterMode == 'all') return filteredItems;
switch (filterMode) {
case 'albums':
return items.where((item) {
return filteredItems.where((item) {
final key =
'${item.albumName}|${item.albumArtist ?? item.artistName}';
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return (albumCounts[key] ?? 0) > 1;
}).toList();
case 'singles':
return items.where((item) {
return filteredItems.where((item) {
final key =
'${item.albumName}|${item.albumArtist ?? item.artistName}';
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return (albumCounts[key] ?? 0) == 1;
}).toList();
default:
return items;
return filteredItems;
}
}
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
// Use lowercase key for case-insensitive grouping
final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
albumMap.putIfAbsent(key, () => []).add(item);
}
int singleTracks = 0;
for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
if ((albumCounts[key] ?? 0) <= 1) {
singleTracks++;
}
@@ -380,7 +622,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
_searchFocusNode.unfocus();
Navigator.push(
context,
PageRouteBuilder(
@@ -395,27 +638,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
).then((_) => _searchFocusNode.unfocus());
}
@override
Widget build(BuildContext context) {
_initializePageController();
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
final isProcessing = ref.watch(
downloadQueueProvider.select((s) => s.isProcessing),
);
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
final queuedCount = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
final completedCount = ref.watch(
downloadQueueProvider.select((s) => s.completedCount),
);
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
_ensureHistoryCaches(allHistoryItems);
final historyViewMode = ref.watch(
settingsProvider.select((s) => s.historyViewMode),
);
@@ -425,7 +659,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final historyStats = _buildHistoryStats(allHistoryItems);
final historyStats =
_historyStatsCache ?? _buildHistoryStats(allHistoryItems);
final groupedAlbums = historyStats.groupedAlbums;
final albumCount = historyStats.albumCount;
final singleCount = historyStats.singleTracks;
@@ -480,68 +715,88 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
},
),
),
),
if ((isProcessing || queuedCount > 0) &&
(queueItems.length > 1 || isPaused))
// Search bar - always at top
if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isPaused
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isPaused ? Icons.pause : Icons.downloading,
color: isPaused
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer,
),
child: GestureDetector(
onTap: () {},
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
autofocus: false,
canRequestFocus: true,
decoration: InputDecoration(
hintText: context.l10n.historySearchHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_clearSearch();
FocusScope.of(context).unfocus();
},
)
: null,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isPaused
? 'Paused'
: '$completedCount/${queueItems.length}',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1.5,
),
FilledButton.tonal(
onPressed: () => ref
.read(downloadQueueProvider.notifier)
.togglePause(),
child: Text(isPaused ? 'Resume' : 'Pause'),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2.5,
),
],
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
onChanged: _onSearchChanged,
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
),
),
),
),
),
if (queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Downloading (${queueItems.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Text(
'Downloading (${queueItems.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
_buildPauseResumeButton(context, ref, colorScheme),
],
),
),
),
),
if (queueItems.isNotEmpty)
SliverList(
@@ -551,7 +806,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
key: ValueKey(item.id),
child: _buildQueueItem(context, item, colorScheme),
);
}, childCount: queueItems.length),
}, childCount: queueItems.length),
),
if (allHistoryItems.isNotEmpty)
@@ -655,42 +910,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return false;
},
child: PageView(
child: PageView.builder(
controller: _filterPageController!,
physics: const ClampingScrollPhysics(),
onPageChanged: _onFilterPageChanged,
children: [
_buildFilterContent(
itemCount: _filterModes.length,
itemBuilder: (context, index) {
final filterMode = _filterModes[index];
return _buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: 'all',
filterMode: filterMode,
allHistoryItems: allHistoryItems,
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
albumCounts: historyStats.albumCounts,
),
_buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: 'albums',
allHistoryItems: allHistoryItems,
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
albumCounts: historyStats.albumCounts,
),
_buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: 'singles',
allHistoryItems: allHistoryItems,
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
albumCounts: historyStats.albumCounts,
),
],
);
},
),
),
),
@@ -702,13 +939,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(
child: _buildSelectionBottomBar(
context,
colorScheme,
_filterHistoryItems(
allHistoryItems,
historyFilterMode,
historyStats.albumCounts,
_resolveHistoryItems(
filterMode: historyFilterMode,
allHistoryItems: allHistoryItems,
albumCounts: historyStats.albumCounts,
),
bottomPadding,
),
@@ -726,10 +963,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required String historyViewMode,
required List<DownloadItem> queueItems,
required List<_GroupedAlbum> groupedAlbums,
required Map<String, int> albumCounts,
required Map<String, int> albumCounts,
}) {
final historyItems =
_filterHistoryItems(allHistoryItems, filterMode, albumCounts);
final historyItems = _resolveHistoryItems(
filterMode: filterMode,
allHistoryItems: allHistoryItems,
albumCounts: albumCounts,
);
final showFilteringIndicator = _shouldShowFilteringIndicator(
allHistoryItems: allHistoryItems,
filterMode: filterMode,
);
// Filter grouped albums based on search query
final searchQuery = _searchQuery;
final filteredGroupedAlbums = searchQuery.isEmpty
? groupedAlbums
: groupedAlbums
.where((album) => album.searchKey.contains(searchQuery))
.toList();
return CustomScrollView(
slivers: [
@@ -763,14 +1015,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
if (groupedAlbums.isNotEmpty &&
if (filteredGroupedAlbums.isNotEmpty &&
queueItems.isEmpty &&
filterMode == 'albums')
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}',
'${filteredGroupedAlbums.length} ${filteredGroupedAlbums.length == 1 ? 'album' : 'albums'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -791,7 +1043,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
if (showFilteringIndicator)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Text(
'Filtering...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
@@ -803,12 +1081,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate((context, index) {
final album = groupedAlbums[index];
final album = filteredGroupedAlbums[index];
return KeyedSubtree(
key: ValueKey(album.key),
child: _buildAlbumGridItem(context, album, colorScheme),
);
}, childCount: groupedAlbums.length),
}, childCount: filteredGroupedAlbums.length),
),
),
@@ -854,9 +1132,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}, childCount: historyItems.length ),
),
if (queueItems.isEmpty &&
if (queueItems.isEmpty &&
historyItems.isEmpty &&
(filterMode != 'albums' || groupedAlbums.isEmpty))
(filterMode != 'albums' || filteredGroupedAlbums.isEmpty) &&
!showFilteringIndicator)
SliverFillRemaining(
hasScrollBody: false,
child: _buildEmptyState(
@@ -873,6 +1152,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
Widget _buildPauseResumeButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
return TextButton.icon(
onPressed: () {
ref.read(downloadQueueProvider.notifier).togglePause();
},
icon: Icon(
isPaused ? Icons.play_arrow : Icons.pause,
size: 18,
),
label: Text(
isPaused ? context.l10n.actionResume : context.l10n.actionPause,
),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
);
}
Widget _buildEmptyState(
BuildContext context,
ColorScheme colorScheme,
+25 -1
View File
@@ -157,7 +157,7 @@ class AboutPage extends StatelessWidget {
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
_AboutSettingsItem(
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
@@ -168,6 +168,30 @@ class AboutPage extends StatelessWidget {
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflacchat'),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
@@ -709,6 +709,7 @@ static const _allLanguages = [
('pt', 'Português', Icons.language),
('pt_PT', 'Português (Brasil)', Icons.language),
('ru', 'Русский', Icons.language),
('tr', 'Türkçe', Icons.language),
('zh', '简体中文', Icons.language),
('zh_CN', '简体中文 (中国)', Icons.language),
('zh_TW', '繁體中文', Icons.language),
@@ -276,6 +276,8 @@ class DownloadSettingsPage extends ConsumerWidget {
return 'Albums/Artist/[Year] Album/';
case 'year_album':
return 'Albums/[Year] Album/';
case 'artist_album_singles':
return 'Artist/Album/ + Artist/Singles/';
default:
return 'Albums/Artist/Album Name/';
}
@@ -328,6 +330,16 @@ class DownloadSettingsPage extends ConsumerWidget {
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.person_outlined),
title: Text(context.l10n.albumFolderArtistAlbumSingles),
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
Navigator.pop(context);
},
),
],
),
),
+12 -4
View File
@@ -19,6 +19,14 @@ class ExtensionsPage extends ConsumerStatefulWidget {
}
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
static final RegExp _platformExceptionPattern =
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
static final RegExp _platformExceptionSimplePattern =
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
static final RegExp _trailingNullsPattern =
RegExp(r',\s*null\s*,\s*null\)?$');
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
@override
void initState() {
super.initState();
@@ -296,19 +304,19 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
String message = error;
if (message.contains('PlatformException')) {
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
final match = _platformExceptionPattern.firstMatch(message);
if (match != null) {
message = match.group(1)?.trim() ?? message;
} else {
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
final simpleMatch = _platformExceptionSimplePattern.firstMatch(message);
if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message;
}
}
}
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
message = message.replaceAll(_trailingNullsPattern, '');
message = message.replaceAll(_leadingCommaPattern, '');
return message;
}
+5 -1
View File
@@ -5,6 +5,9 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
final RegExp _domainPattern =
RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false);
class LogScreen extends StatefulWidget {
const LogScreen({super.key});
@@ -13,6 +16,7 @@ class LogScreen extends StatefulWidget {
}
class _LogScreenState extends State<LogScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
String _selectedLevel = 'ALL';
@@ -633,7 +637,7 @@ class _LogSummaryCard extends StatelessWidget {
combined.contains('connection refused')) {
hasISPBlocking = true;
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
final domainMatch = _domainPattern.firstMatch(combined);
if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!);
}
+161 -34
View File
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
@@ -25,14 +25,20 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false;
int? _fileSize;
String? _lyrics;
String? _lyrics; // Cleaned lyrics for display (no timestamps)
String? _rawLyrics; // Raw LRC with timestamps for embedding
bool _lyricsLoading = false;
String? _lyricsError;
Color? _dominantColor;
bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress
bool _isInstrumental = false; // Track if detected as instrumental
final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern =
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
static final RegExp _lrcMetadataPattern =
RegExp(r'^\[[a-zA-Z]+:.*\]$');
static const List<String> _months = [
'Jan',
'Feb',
@@ -61,7 +67,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
super.initState();
_scrollController.addListener(_onScroll);
_checkFile();
_extractDominantColor();
// Delay palette extraction to avoid jitter during initial build
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
}
@override
@@ -80,25 +89,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Future<void> _extractDominantColor() async {
final coverUrl = widget.item.coverUrl;
if (coverUrl == null || coverUrl.isEmpty) return;
if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) {
// Check cache first
final cachedColor = PaletteService.instance.getCached(coverUrl);
if (cachedColor != null) {
if (mounted && cachedColor != _dominantColor) {
setState(() => _dominantColor = cachedColor);
}
return;
}
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(coverUrl),
size: const Size(128, 128),
maximumColorCount: 12,
);
final nextColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (mounted && nextColor != _dominantColor) {
setState(() {
_dominantColor = nextColor;
});
}
} catch (_) {
// Extract using PaletteService (runs in isolate)
final color = await PaletteService.instance.extractDominantColor(coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() => _dominantColor = color);
}
}
@@ -846,18 +850,62 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
],
),
)
else if (_lyrics != null)
else if (_isInstrumental)
Container(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Text(
_lyrics!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
height: 1.6,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.music_note, color: colorScheme.tertiary, size: 20),
const SizedBox(width: 12),
Text(
context.l10n.trackInstrumental,
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontStyle: FontStyle.italic,
),
),
],
),
)
else if (_lyrics != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Text(
_lyrics!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
height: 1.6,
),
),
),
),
),
// Show "Embed Lyrics" button if lyrics are from online (not already embedded)
if (!_lyricsEmbedded && _fileExists) ...[
const SizedBox(height: 16),
Center(
child: FilledButton.tonalIcon(
onPressed: _isEmbedding ? null : _embedLyrics,
icon: _isEmbedding
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_alt),
label: Text(context.l10n.trackEmbedLyrics),
),
),
],
],
)
else
Center(
@@ -879,26 +927,57 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
setState(() {
_lyricsLoading = true;
_lyricsError = null;
_isInstrumental = false;
});
try {
// Convert duration from seconds to milliseconds
final durationMs = (item.duration ?? 0) * 1000;
// Add timeout to prevent infinite loading
// First, check if lyrics are embedded in the file
if (_fileExists) {
final embeddedResult = await PlatformBridge.getLyricsLRC(
'',
item.trackName,
item.artistName,
filePath: cleanFilePath,
durationMs: 0,
).timeout(const Duration(seconds: 5), onTimeout: () => '');
if (embeddedResult.isNotEmpty) {
// Lyrics found in file
if (mounted) {
final cleanLyrics = _cleanLrcForDisplay(embeddedResult);
setState(() {
_lyrics = cleanLyrics;
_lyricsEmbedded = true;
_lyricsLoading = false;
});
}
return;
}
}
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '',
item.trackName,
item.artistName,
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
filePath: null, // Don't check file again
durationMs: durationMs,
).timeout(
const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout
onTimeout: () => '',
);
if (mounted) {
if (result.isEmpty) {
// Check for instrumental marker
if (result == '[instrumental:true]') {
setState(() {
_isInstrumental = true;
_lyricsLoading = false;
});
} else if (result.isEmpty) {
setState(() {
_lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false;
@@ -907,6 +986,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final cleanLyrics = _cleanLrcForDisplay(result);
setState(() {
_lyrics = cleanLyrics;
_rawLyrics = result; // Keep raw LRC with timestamps for embedding
_lyricsEmbedded = false; // Lyrics from online, not embedded
_lyricsLoading = false;
});
}
@@ -923,13 +1004,59 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
}
Future<void> _embedLyrics() async {
if (_isEmbedding || _rawLyrics == null || !_fileExists) return;
setState(() => _isEmbedding = true);
try {
// Use raw LRC content directly - it already has timestamps and metadata
final result = await PlatformBridge.embedLyricsToFile(
cleanFilePath,
_rawLyrics!,
);
if (mounted) {
if (result['success'] == true) {
setState(() {
_lyricsEmbedded = true;
_isEmbedding = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackLyricsEmbedded)),
);
} else {
setState(() => _isEmbedding = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['error'] ?? 'Failed to embed lyrics')),
);
}
}
} catch (e) {
if (mounted) {
setState(() => _isEmbedding = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n');
final cleanLines = <String>[];
for (final line in lines) {
final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim();
final trimmedLine = line.trim();
// Skip metadata tags
if (_lrcMetadataPattern.hasMatch(trimmedLine)) {
continue;
}
// Remove timestamp and clean up
final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim();
if (cleanLine.isNotEmpty) {
cleanLines.add(cleanLine);
}
+2 -1
View File
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/logger.dart';
class CsvImportService {
static final _log = AppLogger('CsvImportService');
static final RegExp _lineSplitPattern = RegExp(r'\r\n|\r|\n');
static Future<List<Track>> pickAndParseCsv({
void Function(int current, int total)? onProgress,
@@ -123,7 +124,7 @@ class CsvImportService {
static List<Track> _parseCsv(String content) {
final List<Track> tracks = [];
final lines = content.split(RegExp(r'\r\n|\r|\n'));
final lines = content.split(_lineSplitPattern);
if (lines.isEmpty) return tracks;
int startIdx = 0;
+437
View File
@@ -0,0 +1,437 @@
import 'dart:convert';
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
/// Cached current iOS container path for path normalization
String? _currentContainerPath;
/// SQLite database service for download history
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database;
HistoryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('history.db');
return _database!;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing database at: $path');
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
}
Future<void> _createDB(Database db, int version) async {
_log.i('Creating database schema v$version');
await db.execute('''
CREATE TABLE history (
id TEXT PRIMARY KEY,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
album_artist TEXT,
cover_url TEXT,
file_path TEXT NOT NULL,
service TEXT NOT NULL,
downloaded_at TEXT NOT NULL,
isrc TEXT,
spotify_id TEXT,
track_number INTEGER,
disc_number INTEGER,
duration INTEGER,
release_date TEXT,
quality TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
genre TEXT,
label TEXT,
copyright TEXT
)
''');
// Indexes for fast lookups
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
_log.i('Database schema created with indexes');
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
// Future migrations go here
}
// ==================== iOS Path Normalization ====================
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
caseSensitive: false,
);
/// Initialize and cache the current iOS container path
Future<void> _initContainerPath() async {
if (!Platform.isIOS || _currentContainerPath != null) return;
try {
final docDir = await getApplicationDocumentsDirectory();
// Extract container path up to and including the UUID folder
// e.g., /var/mobile/Containers/Data/Application/UUID/
final match = _iosContainerPattern.firstMatch(docDir.path);
if (match != null) {
_currentContainerPath = match.group(0);
_log.d('iOS container path: $_currentContainerPath');
}
} catch (e) {
_log.w('Failed to get iOS container path: $e');
}
}
/// Normalize iOS file path by replacing old container UUID with current one
/// This fixes the issue where iOS changes container UUID after app updates
String _normalizeIosPath(String? filePath) {
if (filePath == null || filePath.isEmpty) return filePath ?? '';
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
// Check if path contains an iOS container path
if (_iosContainerPattern.hasMatch(filePath)) {
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
if (normalized != filePath) {
_log.d('Normalized iOS path: $filePath -> $normalized');
}
return normalized;
}
return filePath;
}
/// Migrate iOS paths in database to use current container UUID
/// This is called once after app update if container changed
Future<bool> migrateIosContainerPaths() async {
if (!Platform.isIOS) return false;
await _initContainerPath();
if (_currentContainerPath == null) return false;
final prefs = await _prefs;
final lastContainer = prefs.getString('ios_last_container_path');
// Skip if container hasn't changed
if (lastContainer == _currentContainerPath) {
_log.d('iOS container path unchanged, skipping migration');
return false;
}
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
try {
final db = await database;
// Get all items with iOS paths
final rows = await db.query('history', columns: ['id', 'file_path']);
int updatedCount = 0;
final batch = db.batch();
for (final row in rows) {
final id = row['id'] as String;
final oldPath = row['file_path'] as String?;
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
final newPath = _normalizeIosPath(oldPath);
if (newPath != oldPath) {
batch.update(
'history',
{'file_path': newPath},
where: 'id = ?',
whereArgs: [id],
);
updatedCount++;
}
}
}
if (updatedCount > 0) {
await batch.commit(noResult: true);
}
// Save current container path
await prefs.setString('ios_last_container_path', _currentContainerPath!);
_log.i('iOS path migration complete: $updatedCount paths updated');
return updatedCount > 0;
} catch (e, stack) {
_log.e('iOS path migration failed: $e', e, stack);
return false;
}
}
/// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async {
final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite';
if (prefs.getBool(migrationKey) == true) {
_log.d('Already migrated to SQLite');
return false;
}
final jsonStr = prefs.getString('download_history');
if (jsonStr == null || jsonStr.isEmpty) {
_log.d('No SharedPreferences history to migrate');
await prefs.setBool(migrationKey, true);
return false;
}
try {
final List<dynamic> jsonList = jsonDecode(jsonStr);
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
final db = await database;
final batch = db.batch();
for (final json in jsonList) {
final map = json as Map<String, dynamic>;
batch.insert(
'history',
_jsonToDbRow(map),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
// Mark as migrated but keep old data for safety
await prefs.setBool(migrationKey, true);
_log.i('Migration complete: ${jsonList.length} items');
return true;
} catch (e, stack) {
_log.e('Migration failed: $e', e, stack);
return false;
}
}
/// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
'id': json['id'],
'track_name': json['trackName'],
'artist_name': json['artistName'],
'album_name': json['albumName'],
'album_artist': json['albumArtist'],
'cover_url': json['coverUrl'],
'file_path': json['filePath'],
'service': json['service'],
'downloaded_at': json['downloadedAt'],
'isrc': json['isrc'],
'spotify_id': json['spotifyId'],
'track_number': json['trackNumber'],
'disc_number': json['discNumber'],
'duration': json['duration'],
'release_date': json['releaseDate'],
'quality': json['quality'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'genre': json['genre'],
'label': json['label'],
'copyright': json['copyright'],
};
}
/// Convert DB row (snake_case) to JSON format (camelCase)
/// Also normalizes iOS paths if container UUID changed
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
'trackName': row['track_name'],
'artistName': row['artist_name'],
'albumName': row['album_name'],
'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'],
'filePath': _normalizeIosPath(row['file_path'] as String?),
'service': row['service'],
'downloadedAt': row['downloaded_at'],
'isrc': row['isrc'],
'spotifyId': row['spotify_id'],
'trackNumber': row['track_number'],
'discNumber': row['disc_number'],
'duration': row['duration'],
'releaseDate': row['release_date'],
'quality': row['quality'],
'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'],
'genre': row['genre'],
'label': row['label'],
'copyright': row['copyright'],
};
}
// ==================== CRUD Operations ====================
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
'history',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
final rows = await db.query(
'history',
orderBy: 'downloaded_at DESC',
limit: limit,
offset: offset,
);
return rows.map(_dbRowToJson).toList();
}
/// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
final rows = await db.query(
'history',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by Spotify ID - O(1) with index
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database;
final rows = await db.query(
'history',
where: 'spotify_id = ?',
whereArgs: [spotifyId],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
final rows = await db.query(
'history',
where: 'isrc = ?',
whereArgs: [isrc],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Check if spotify_id exists - O(1) with index
Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database;
final result = await db.rawQuery(
'SELECT 1 FROM history WHERE spotify_id = ? LIMIT 1',
[spotifyId],
);
return result.isNotEmpty;
}
/// Get all spotify_ids as Set for fast in-memory lookup
Future<Set<String>> getAllSpotifyIds() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
);
return rows.map((r) => r['spotify_id'] as String).toSet();
}
/// Delete by ID
Future<void> deleteById(String id) async {
final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]);
}
/// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
}
/// Clear all history
Future<void> clearAll() async {
final db = await database;
await db.delete('history');
_log.i('Cleared all history');
}
/// Get total count
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
return Sqflite.firstIntValue(result) ?? 0;
}
/// Find existing item by spotify_id or isrc (for deduplication)
Future<Map<String, dynamic>?> findExisting({
String? spotifyId,
String? isrc,
}) async {
if (spotifyId != null && spotifyId.isNotEmpty) {
final bySpotify = await getBySpotifyId(spotifyId);
if (bySpotify != null) return bySpotify;
// Check for deezer: prefix matching
if (spotifyId.startsWith('deezer:')) {
final deezerId = spotifyId.substring(7);
final db = await database;
final rows = await db.query(
'history',
where: 'spotify_id LIKE ?',
whereArgs: ['deezer:$deezerId'],
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
}
}
if (isrc != null && isrc.isNotEmpty) {
return await getByIsrc(isrc);
}
return null;
}
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
/// Service for extracting dominant colors from images
/// Uses caching to avoid re-extraction and small image size for speed
class PaletteService {
static final PaletteService instance = PaletteService._();
PaletteService._();
/// Cache for already computed colors
final Map<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
/// Uses small image size and limited colors for speed
Future<Color?> extractDominantColor(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
return null;
}
final cached = _colorCache[imageUrl];
if (cached != null) {
return cached;
}
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[imageUrl] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService error: $e');
return null;
}
}
/// Clear the color cache
void clearCache() {
_colorCache.clear();
}
/// Get cached color without computing
Color? getCached(String? imageUrl) {
if (imageUrl == null) return null;
return _colorCache[imageUrl];
}
}
+28
View File
@@ -794,6 +794,34 @@ class PlatformBridge {
}
}
/// Get extension home feed
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
'extension_id': extensionId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getExtensionHomeFeed failed: $e');
return null;
}
}
/// Get extension browse categories
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
'extension_id': extensionId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getExtensionBrowseCategories failed: $e');
return null;
}
}
static Future<Map<String, dynamic>> runPostProcessing(
String filePath, {
+8 -4
View File
@@ -9,6 +9,12 @@ class ShareIntentService {
factory ShareIntentService() => _instance;
ShareIntentService._internal();
static final RegExp _spotifyUriPattern =
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
static final RegExp _spotifyUrlPattern = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
);
final _sharedUrlController = StreamController<String>.broadcast();
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
bool _initialized = false;
@@ -57,14 +63,12 @@ class ShareIntentService {
String? _extractSpotifyUrl(String text) {
if (text.isEmpty) return null;
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
final uriMatch = _spotifyUriPattern.firstMatch(text);
if (uriMatch != null) {
return uriMatch.group(0);
}
final urlMatch = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
).firstMatch(text);
final urlMatch = _spotifyUrlPattern.firstMatch(text);
if (urlMatch != null) {
final fullUrl = urlMatch.group(0)!;
final queryIndex = fullUrl.indexOf('?');
+5 -3
View File
@@ -159,15 +159,17 @@ class LogBuffer extends ChangeNotifier {
}
List<LogEntry> filter({String? level, String? tag, String? search}) {
final tagLower = tag?.toLowerCase();
final searchLower = search?.toLowerCase();
return _entries.where((entry) {
if (level != null && level != 'ALL' && entry.level != level) {
return false;
}
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) {
if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) {
return false;
}
if (search != null && search.isNotEmpty) {
final searchLower = search.toLowerCase();
if (searchLower != null && searchLower.isNotEmpty) {
return entry.message.toLowerCase().contains(searchLower) ||
entry.tag.toLowerCase().contains(searchLower) ||
(entry.error?.toLowerCase().contains(searchLower) ?? false);
+17 -8
View File
@@ -26,6 +26,15 @@ class _UpdateDialogState extends State<UpdateDialog> {
bool _isDownloading = false;
double _progress = 0;
String _statusText = '';
static final RegExp _whatsNewPattern =
RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false);
static final RegExp _cutoffPattern =
RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false);
static final RegExp _sectionPattern = RegExp(r'^#{1,3}\s*(.+)$');
static final RegExp _listPattern = RegExp(r'^[-*]\s+(.+)$');
static final RegExp _subListPattern = RegExp(r'^\s+[-*]\s+(.+)$');
static final RegExp _boldPattern = RegExp(r'\*\*([^*]+)\*\*');
static final RegExp _codePattern = RegExp(r'`([^`]+)`');
Future<void> _downloadAndInstall() async {
final apkUrl = widget.updateInfo.apkDownloadUrl;
@@ -293,12 +302,12 @@ class _UpdateDialogState extends State<UpdateDialog> {
String _formatChangelog(String changelog) {
var content = changelog;
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
final whatsNewMatch = _whatsNewPattern.firstMatch(content);
if (whatsNewMatch != null) {
content = content.substring(whatsNewMatch.end);
}
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
final cutoffMatch = _cutoffPattern.firstMatch(content);
if (cutoffMatch != null) {
content = content.substring(0, cutoffMatch.start);
}
@@ -310,7 +319,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
line = line.trim();
if (line.isEmpty) continue;
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
final sectionMatch = _sectionPattern.firstMatch(line);
if (sectionMatch != null) {
final section = sectionMatch.group(1)?.trim();
if (section != null && section.isNotEmpty) {
@@ -320,19 +329,19 @@ class _UpdateDialogState extends State<UpdateDialog> {
continue;
}
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
final listMatch = _listPattern.firstMatch(line);
if (listMatch != null) {
var itemText = listMatch.group(1) ?? '';
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? '');
itemText = itemText.replaceAllMapped(_codePattern, (m) => m.group(1) ?? '');
formattedLines.add('$itemText');
continue;
}
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
final subListMatch = _subListPattern.firstMatch(line);
if (subListMatch != null) {
var itemText = subListMatch.group(1) ?? '';
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? '');
formattedLines.add(' - $itemText');
continue;
}
+1 -1
View File
@@ -1027,7 +1027,7 @@ packages:
source: hosted
version: "1.10.1"
sqflite:
dependency: transitive
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
+2 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.1.3+62
version: 3.2.1+64
environment:
sdk: ^3.10.0
@@ -26,6 +26,7 @@ dependencies:
shared_preferences: ^2.5.3
path_provider: ^2.1.5
path: ^1.9.0
sqflite: ^2.4.1
# HTTP & Network
http: ^1.6.0
+2 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.1.3+62
version: 3.2.1+64
environment:
sdk: ^3.10.0
@@ -26,6 +26,7 @@ dependencies:
shared_preferences: ^2.5.3
path_provider: ^2.1.5
path: ^1.9.0
sqflite: ^2.4.1
# HTTP & Network
http: ^1.6.0