Compare commits

...

6 Commits

Author SHA1 Message Date
zarzet 6388f3a5b8 perf: optimize providers, caching, and reduce rebuilds
- Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access)
- Pre-compute download counts in queue provider to avoid repeated filtering
- Add identical() caching for RecentAccessView in HomeTab
- Use selective watching for exploreProvider (sections, greeting, isLoading only)
- Move isYTMusicQuickPicks computation to ExploreSection.fromJson()
- Hoist static RegExp patterns to avoid repeated compilation
- Use batch operations for iOS path migration in history_database
- Replace containsKey+lookup with single lookup in palette_service
- Pre-compute lowercase strings outside filter loops in logger
- Fix _isLoaded race condition in DownloadHistoryNotifier
2026-01-22 03:56:47 +07:00
zarzet 55b75dc48d chore: bump version to 3.2.1+64 2026-01-22 02:17:47 +07:00
zarzet f6cea1a683 feat: v3.2.1 - lyrics improvements, pause/resume, folder options
- Add instrumental track detection (shows 'Instrumental track' instead of 'not available')
- Add embed lyrics button in Track Info (preserves synced timestamps)
- Add pause/resume button next to 'Downloading' header in History
- Add Artist/Album + Singles folder structure option
- Fix multi-artist lyrics search (try primary artist first)
- Fix lyrics display stripping metadata tags ([ti:], [ar:], [by:])
- Skip lyrics embedding for instrumental tracks during download
2026-01-22 02:15:43 +07:00
zarzet 8d205600b8 fix: iOS path migration, local greeting timezone, ICU plural warnings
- iOS: Auto-migrate file paths when container UUID changes after app update
- Greeting: Use device local time instead of extension response
- i18n: Fix 16 ICU plural syntax warnings in Spanish and Portuguese
2026-01-22 00:48:45 +07:00
zarzet aa35f60fad fix: fallback to index+1 for Deezer track position when API returns 0 2026-01-21 16:33:30 +07:00
zarzet b627ae1874 fix: handle CRLF in changelog extraction for Telegram 2026-01-21 16:23:19 +07:00
41 changed files with 988 additions and 196 deletions
+9 -1
View File
@@ -441,7 +441,11 @@ jobs:
VERSION_NUM=${VERSION#v} VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) # 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 if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details." CHANGELOG="See release notes on GitHub for details."
@@ -451,7 +455,9 @@ jobs:
# - `code` → <code>code</code> # - `code` → <code>code</code>
# - ### Header → <b>Header</b> # - ### Header → <b>Header</b>
# - Escape HTML special chars first # - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \ CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
sed 's/&/\&amp;/g' | \ sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \ sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \ sed 's/>/\&gt;/g' | \
@@ -473,6 +479,8 @@ jobs:
fi fi
echo "$CHANGELOG" > /tmp/changelog.txt echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel - name: Send to Telegram Channel
env: env:
+33
View File
@@ -1,5 +1,38 @@
# Changelog # Changelog
## [3.2.1] - 2026-01-22
> **Note:** Next release will use `year.month.day` format (e.g., 26.2.1) and is scheduled for early February. Developer is taking a short break!
### Added
- **Artist/Album + Singles Folder Structure**: Singles go inside artist folder (`Artist/Album/`, `Artist/Singles/`)
- **Embed Lyrics Button**: Manually embed online lyrics into tracks from Track Info screen (preserves synced timestamps)
- **Pause/Resume Button**: Added pause and resume controls next to "Downloading" header in History screen
- **Instrumental Detection**: Tracks marked as instrumental on lrclib.net now show "Instrumental track" instead of "Lyrics not available"
### Fixed
- **Lyrics**: Multi-artist tracks now search by primary artist first, then full string
- **Lyrics**: Metadata tags (`[ti:...]`, `[ar:...]`, `[by:...]`) no longer shown in display
- **Lyrics**: Embed button now correctly appears for tracks with online lyrics
- **Lyrics**: Manual embed preserves original timestamps instead of plain text
- **iOS**: Fixed "File not found" after 3.1.x → 3.2.0 update (container UUID migration)
- **Home Feed**: Greeting now uses device local time
- **Deezer**: Track position fallback to index+1 when API returns 0
- **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese
### Performance
- **Home Feed**: Precomputed Quick Picks section flag and reduced per-page allocations; explore state now watched by field to cut rebuilds
- **Home Recent**: Cached recent-access aggregation and limited list allocations for recent downloads
- **Settings/Theme/Recent**: Cached SharedPreferences instance to avoid repeated `getInstance()` calls
- **History/DB**: Batched iOS path migration updates to reduce write overhead
- **Download Queue**: Reduced polling allocations and avoided double-load scheduling for history
- **Misc**: Precompiled regex in share intent, update dialog, extensions error parsing, log analysis, and LRC cleanup; faster palette cache hits and log filtering
---
## [3.2.0] - 2026-01-22 ## [3.2.0] - 2026-01-22
> **Note:** Starting from v3.2.0, changelogs will be concise. > **Note:** Starting from v3.2.0, changelogs will be concise.
+8 -2
View File
@@ -340,10 +340,16 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
albumType = "compilation" albumType = "compilation"
} }
for _, track := range album.Tracks.Data { for i, track := range album.Tracks.Data {
trackIDStr := fmt.Sprintf("%d", track.ID) trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr] 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{ tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID), SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name, Artists: track.Artist.Name,
@@ -353,7 +359,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
DurationMS: track.Duration * 1000, DurationMS: track.Duration * 1000,
Images: albumImage, Images: albumImage,
ReleaseDate: album.ReleaseDate, ReleaseDate: album.ReleaseDate,
TrackNumber: track.TrackPosition, TrackNumber: trackNum,
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
ExternalURL: track.Link, ExternalURL: track.Link,
+20 -5
View File
@@ -615,10 +615,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
} }
result := map[string]interface{}{ result := map[string]interface{}{
"success": true, "success": true,
"source": lyrics.Source, "source": lyrics.Source,
"sync_type": lyrics.SyncType, "sync_type": lyrics.SyncType,
"lines": lyrics.Lines, "lines": lyrics.Lines,
"instrumental": lyrics.Instrumental,
} }
jsonBytes, err := json.Marshal(result) 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) { 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 != "" { if filePath != "" {
lyrics, err := ExtractLyrics(filePath) lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" { if err == nil && lyrics != "" {
return lyrics, nil return lyrics, nil
} }
// File has no lyrics - return empty, let Flutter call again without filePath
return "", nil
} }
client := NewLyricsClient() client := NewLyricsClient()
@@ -644,6 +649,11 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return "", err return "", err
} }
// Return special marker for instrumental tracks
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName) lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil return lrcContent, nil
} }
@@ -1698,6 +1708,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
if trackCover == "" { if trackCover == "" {
trackCover = album.CoverURL 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{}{ tracks[i] = map[string]interface{}{
"id": track.ID, "id": track.ID,
"name": track.Name, "name": track.Name,
@@ -1707,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"duration_ms": track.DurationMS, "duration_ms": track.DurationMS,
"cover_url": trackCover, "cover_url": trackCover,
"release_date": track.ReleaseDate, "release_date": track.ReleaseDate,
"track_number": track.TrackNumber, "track_number": trackNum,
"disc_number": track.DiscNumber, "disc_number": track.DiscNumber,
"isrc": track.ISRC, "isrc": track.ISRC,
"provider_id": track.ProviderID, "provider_id": track.ProviderID,
+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 // 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) { 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 { if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached cachedCopy := *cached
@@ -251,29 +254,44 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var lyrics *LyricsResponse var lyrics *LyricsResponse
var err error var err error
// Try exact match first // Helper to check if lyrics result is valid (has lines OR is instrumental)
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) isValidResult := func(l *LyricsResponse) bool {
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { 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" lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil 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 // Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName) simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack) lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)" lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
} }
// Search with duration matching // Search with duration matching (use primary artist for search)
query := artistName + " " + trackName query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search" lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
@@ -281,9 +299,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
// Search with simplified name and duration matching // Search with simplified name and duration matching
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) 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)" lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
@@ -462,6 +480,24 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result) 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) { func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" { if lrcContent == "" {
return "", fmt.Errorf("empty LRC content") return "", fmt.Errorf("empty LRC content")
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.2.0'; static const String version = '3.2.1';
static const String buildNumber = '63'; static const String buildNumber = '64';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+30
View File
@@ -2962,6 +2962,24 @@ abstract class AppLocalizations {
/// **'Failed to load lyrics'** /// **'Failed to load lyrics'**
String get trackLyricsLoadFailed; 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 /// Snackbar - content copied
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3688,6 +3706,18 @@ abstract class AppLocalizations {
/// **'Albums/[2005] Album Name/'** /// **'Albums/[2005] Album Name/'**
String get albumFolderYearAlbumSubtitle; 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 /// Button - delete selected tracks
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+16
View File
@@ -1631,6 +1631,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2019,6 +2028,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1628,6 +1628,15 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Gagal memuat lirik'; 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 @override
String get trackCopiedToClipboard => 'Disalin ke clipboard'; String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -2019,6 +2028,13 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1652,6 +1652,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни'; String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Скопировано в буфер обмена'; String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
@@ -2047,6 +2056,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderYearAlbumSubtitle => String get albumFolderYearAlbumSubtitle =>
'Альбомы/[2005] Название Альбома /'; 'Альбомы/[2005] Название Альбома /';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+16
View File
@@ -1618,6 +1618,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; 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 @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2006,6 +2015,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
+10
View File
@@ -1188,6 +1188,12 @@
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"}, "@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
"trackLyricsLoadFailed": "Failed to load lyrics", "trackLyricsLoadFailed": "Failed to load lyrics",
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"}, "@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": "Copied to clipboard",
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"}, "@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
"trackDeleteConfirmTitle": "Remove from device?", "trackDeleteConfirmTitle": "Remove from device?",
@@ -1477,6 +1483,10 @@
"@albumFolderYearAlbum": {"description": "Album folder option with year"}, "@albumFolderYearAlbum": {"description": "Album folder option with year"},
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"}, "@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": "Delete Selected",
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"}, "@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
+49 -15
View File
@@ -180,9 +180,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
/// Synchronously schedule load - ensures it runs before any UI renders /// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromDatabaseSync() { void _loadFromDatabaseSync() {
if (_isLoaded) return; if (_isLoaded) return;
_isLoaded = true;
Future.microtask(() async { Future.microtask(() async {
await _loadFromDatabase(); await _loadFromDatabase();
_isLoaded = true;
}); });
} }
@@ -193,6 +193,14 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_historyLog.i('Migrated history from SharedPreferences to SQLite'); _historyLog.i('Migrated history from SharedPreferences to SQLite');
} }
// 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');
}
}
final jsonList = await _db.getAll(); final jsonList = await _db.getAll();
final items = jsonList final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e)) .map((e) => DownloadHistoryItem.fromJson(e))
@@ -467,10 +475,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final currentItems = state.items; final currentItems = state.items;
final itemsById = <String, DownloadItem>{}; final itemsById = <String, DownloadItem>{};
final itemIndexById = <String, int>{}; final itemIndexById = <String, int>{};
int queuedCount = 0;
int downloadingCount = 0;
DownloadItem? firstDownloading;
for (int i = 0; i < currentItems.length; i++) { for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i]; final item = currentItems[i];
itemsById[item.id] = item; itemsById[item.id] = item;
itemIndexById[item.id] = i; 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>{}; final progressUpdates = <String, _ProgressUpdate>{};
@@ -592,15 +611,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
final downloadingItems = state.items if (downloadingCount > 0 && firstDownloading != null) {
.where((i) => i.status == DownloadStatus.downloading) final trackName = downloadingCount == 1
.toList(); ? firstDownloading.track.name
if (downloadingItems.isNotEmpty) { : '$downloadingCount downloads';
final trackName = downloadingItems.length == 1 final artistName = downloadingCount == 1
? downloadingItems.first.track.name ? firstDownloading.track.artistName
: '${downloadingItems.length} downloads';
final artistName = downloadingItems.length == 1
? downloadingItems.first.track.artistName
: 'Downloading...'; : 'Downloading...';
int notifProgress = bytesReceived; int notifProgress = bytesReceived;
@@ -622,11 +638,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (Platform.isAndroid) { if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress( PlatformBridge.updateDownloadServiceProgress(
trackName: downloadingItems.first.track.name, trackName: firstDownloading.track.name,
artistName: downloadingItems.first.track.artistName, artistName: firstDownloading.track.artistName,
progress: notifProgress, progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1, total: notifTotal > 0 ? notifTotal : 1,
queueCount: state.queuedCount, queueCount: queuedCount,
).catchError((_) {}); ).catchError((_) {});
} }
} }
@@ -704,14 +720,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; 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) { if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder'); await _ensureDirExists(singlesPath, label: 'Singles folder');
return singlesPath; return singlesPath;
} else { } else {
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
final artistName = _sanitizeFolderName(albumArtist);
final year = _extractYear(track.releaseDate); final year = _extractYear(track.releaseDate);
String albumPath; String albumPath;
@@ -1161,10 +1192,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
durationMs: durationMs, durationMs: durationMs,
); );
if (lrcContent.isNotEmpty) { // Skip instrumental tracks (no lyrics to embed)
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent; metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
} else if (lrcContent == '[instrumental:true]') {
_log.d('Track is instrumental, skipping lyrics embedding');
} }
} catch (e) { } catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e'); _log.w('Failed to fetch lyrics for embedding: $e');
+39 -4
View File
@@ -55,21 +55,26 @@ class ExploreSection {
final String uri; final String uri;
final String title; final String title;
final List<ExploreItem> items; final List<ExploreItem> items;
final bool isYTMusicQuickPicks;
const ExploreSection({ const ExploreSection({
required this.uri, required this.uri,
required this.title, required this.title,
required this.items, required this.items,
this.isYTMusicQuickPicks = false,
}); });
factory ExploreSection.fromJson(Map<String, dynamic> json) { factory ExploreSection.fromJson(Map<String, dynamic> json) {
final itemsList = json['items'] as List<dynamic>? ?? []; 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( return ExploreSection(
uri: json['uri'] as String? ?? '', uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
items: itemsList items: items,
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>)) isYTMusicQuickPicks: isQuickPicks,
.toList(),
); );
} }
} }
@@ -109,6 +114,31 @@ class ExploreState {
} }
} }
/// 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 /// Provider for explore/home feed state
class ExploreNotifier extends Notifier<ExploreState> { class ExploreNotifier extends Notifier<ExploreState> {
@override @override
@@ -201,9 +231,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); _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( state = ExploreState(
isLoading: false, isLoading: false,
greeting: greeting, greeting: localGreeting,
sections: sections, sections: sections,
lastFetched: DateTime.now(), lastFetched: DateTime.now(),
); );
+5 -3
View File
@@ -100,6 +100,8 @@ class RecentAccessState {
/// Provider for managing recent access history /// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> { class RecentAccessNotifier extends Notifier<RecentAccessState> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override @override
RecentAccessState build() { RecentAccessState build() {
_loadHistory(); _loadHistory();
@@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final json = prefs.getString(_recentAccessKey); final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
@@ -132,13 +134,13 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
Future<void> _saveHistory() async { Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json); await prefs.setString(_recentAccessKey, json);
} }
Future<void> _saveHiddenDownloads() async { Future<void> _saveHiddenDownloads() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
} }
+4 -2
View File
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1; const _currentMigrationVersion = 1;
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override @override
AppSettings build() { AppSettings build() {
_loadSettings(); _loadSettings();
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(jsonDecode(json));
@@ -46,7 +48,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
await prefs.setString(_settingsKey, jsonEncode(state.toJson())); 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'; import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider'); final _log = AppLogger('StoreProvider');
final RegExp _leadingVersionPrefix = RegExp(r'^v');
/// Compare two semantic version strings /// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2 /// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) { int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.'); final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.'); final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; 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 /// Notifier for managing theme settings with persistence
class ThemeNotifier extends Notifier<ThemeSettings> { class ThemeNotifier extends Notifier<ThemeSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override @override
ThemeSettings build() { ThemeSettings build() {
// Load settings asynchronously on first access // Load settings asynchronously on first access
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
/// Load theme settings from SharedPreferences /// Load theme settings from SharedPreferences
Future<void> _loadFromStorage() async { Future<void> _loadFromStorage() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final modeString = prefs.getString(kThemeModeKey); final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey); final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey); final seedColor = prefs.getInt(kSeedColorKey);
@@ -40,7 +42,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
/// Save current settings to SharedPreferences /// Save current settings to SharedPreferences
Future<void> _saveToStorage() async { Future<void> _saveToStorage() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
await prefs.setString(kThemeModeKey, state.themeMode.name); await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor); await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue); await prefs.setInt(kSeedColorKey, state.seedColorValue);
+158 -100
View File
@@ -29,6 +29,18 @@ class HomeTab extends ConsumerStatefulWidget {
ConsumerState<HomeTab> createState() => _HomeTabState(); ConsumerState<HomeTab> createState() => _HomeTabState();
} }
class _RecentAccessView {
final List<RecentAccessItem> uniqueItems;
final List<RecentAccessItem> downloadItems;
final bool hasHiddenDownloads;
const _RecentAccessView({
required this.uniqueItems,
required this.downloadItems,
required this.hasHiddenDownloads,
});
}
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController(); final _urlController = TextEditingController();
bool _isTyping = false; bool _isTyping = false;
@@ -51,6 +63,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
/// Debounce duration for live search /// Debounce duration for live search
static const Duration _liveSearchDelay = Duration(milliseconds: 800); static const Duration _liveSearchDelay = Duration(milliseconds: 800);
List<DownloadHistoryItem>? _recentAccessHistoryCache;
List<RecentAccessItem>? _recentAccessItemsCache;
Set<String>? _recentAccessHiddenIdsCache;
_RecentAccessView? _recentAccessViewCache;
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -447,7 +464,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final error = ref.watch(trackProvider.select((s) => s.error)); final error = ref.watch(trackProvider.select((s) => s.error));
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore)); final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
final exploreState = ref.watch(exploreProvider); final exploreSections =
ref.watch(exploreProvider.select((s) => s.sections));
final exploreGreeting =
ref.watch(exploreProvider.select((s) => s.greeting));
final exploreLoading =
ref.watch(exploreProvider.select((s) => s.isLoading));
final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) => final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) =>
s.extensions.any((e) => e.enabled && e.hasHomeFeed) s.extensions.any((e) => e.enabled && e.hasHomeFeed)
)); ));
@@ -461,11 +483,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final topPadding = mediaQuery.padding.top; final topPadding = mediaQuery.padding.top;
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items)); final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
final hiddenDownloadIds =
ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds));
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
final recentAccessView = showRecentAccess
? _getRecentAccessView(recentAccessItems, historyItems, hiddenDownloadIds)
: null;
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent; final hasExploreContent = exploreSections.isNotEmpty;
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent;
if (hasActualResults && isShowingRecentAccess) { if (hasActualResults && isShowingRecentAccess) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -577,11 +605,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (showRecentAccess) if (showRecentAccess)
SliverToBoxAdapter( SliverToBoxAdapter(
child: _buildRecentAccess( child: _buildRecentAccess(recentAccessView!, colorScheme),
recentAccessItems,
historyItems,
colorScheme,
),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -614,9 +638,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
), ),
if (showExplore) if (showExplore)
..._buildExploreSections(exploreState, colorScheme), ..._buildExploreSections(exploreSections, exploreGreeting, colorScheme),
if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading) if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreLoading)
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
@@ -640,7 +664,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
Widget _buildRecentDownloads(List<DownloadHistoryItem> items, ColorScheme colorScheme) { Widget _buildRecentDownloads(List<DownloadHistoryItem> items, ColorScheme colorScheme) {
final displayItems = items.take(10).toList(); final itemCount = items.length < 10 ? items.length : 10;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -658,9 +682,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
height: 130, height: 130,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: displayItems.length, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = displayItems[index]; final item = items[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(item.id), key: ValueKey(item.id),
child: GestureDetector( child: GestureDetector(
@@ -711,10 +735,117 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
); );
} }
List<Widget> _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) { _RecentAccessView _getRecentAccessView(
final greeting = exploreState.greeting; List<RecentAccessItem> items,
List<DownloadHistoryItem> historyItems,
Set<String> hiddenIds,
) {
final cached = _recentAccessViewCache;
if (cached != null &&
identical(historyItems, _recentAccessHistoryCache) &&
identical(items, _recentAccessItemsCache) &&
identical(hiddenIds, _recentAccessHiddenIdsCache)) {
return cached;
}
final albumGroups = <String, List<DownloadHistoryItem>>{};
for (final h in historyItems) {
final artistForKey =
(h.albumArtist != null && h.albumArtist!.isNotEmpty)
? h.albumArtist!
: h.artistName;
final albumKey = '${h.albumName}|$artistForKey';
albumGroups.putIfAbsent(albumKey, () => []).add(h);
}
final downloadItems = <RecentAccessItem>[];
for (final entry in albumGroups.entries) {
final tracks = entry.value;
final mostRecent = tracks.reduce(
(a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b,
);
final artistForKey =
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
? mostRecent.albumArtist!
: mostRecent.artistName;
if (tracks.length == 1) {
downloadItems.add(
RecentAccessItem(
id: mostRecent.spotifyId ?? mostRecent.id,
name: mostRecent.trackName,
subtitle: mostRecent.artistName,
imageUrl: mostRecent.coverUrl,
type: RecentAccessType.track,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
),
);
} else {
downloadItems.add(
RecentAccessItem(
id: '${mostRecent.albumName}|$artistForKey',
name: mostRecent.albumName,
subtitle: artistForKey,
imageUrl: mostRecent.coverUrl,
type: RecentAccessType.album,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
),
);
}
}
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
final visibleDownloads = <RecentAccessItem>[];
for (final item in downloadItems) {
if (!hiddenIds.contains(item.id)) {
visibleDownloads.add(item);
if (visibleDownloads.length >= 10) {
break;
}
}
}
final allItems = <RecentAccessItem>[
...items,
...visibleDownloads,
];
allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
final seen = <String>{};
final uniqueItems = <RecentAccessItem>[];
for (final item in allItems) {
final key = '${item.type.name}:${item.id}';
if (seen.add(key)) {
uniqueItems.add(item);
if (uniqueItems.length >= 10) {
break;
}
}
}
final view = _RecentAccessView(
uniqueItems: uniqueItems,
downloadItems: downloadItems,
hasHiddenDownloads: hiddenIds.isNotEmpty,
);
_recentAccessHistoryCache = historyItems;
_recentAccessItemsCache = items;
_recentAccessHiddenIdsCache = hiddenIds;
_recentAccessViewCache = view;
return view;
}
List<Widget> _buildExploreSections(
List<ExploreSection> sections,
String? greeting,
ColorScheme colorScheme,
) {
final hasGreeting = greeting != null && greeting.isNotEmpty; final hasGreeting = greeting != null && greeting.isNotEmpty;
final sections = exploreState.sections;
final sectionOffset = hasGreeting ? 1 : 0; final sectionOffset = hasGreeting ? 1 : 0;
final totalCount = sections.length + sectionOffset + 1; // + bottom padding final totalCount = sections.length + sectionOffset + 1; // + bottom padding
@@ -749,9 +880,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
final isYTMusicQuickPicks = _isYTMusicQuickPicksSection(section); if (section.isYTMusicQuickPicks) {
if (isYTMusicQuickPicks) {
return _buildYTMusicQuickPicksSection(section, colorScheme); return _buildYTMusicQuickPicksSection(section, colorScheme);
} }
@@ -783,19 +912,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
); );
} }
bool _isYTMusicQuickPicksSection(ExploreSection section) {
if (section.items.isEmpty) return false;
if (section.items.first.providerId != 'ytmusic-spotiflac') return false;
for (final item in section.items) {
if (item.type != 'track') {
return false;
}
}
return true;
}
/// Build YT Music "Quick picks" style swipeable pages section /// Build YT Music "Quick picks" style swipeable pages section
Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) { Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) {
const itemsPerPage = 5; const itemsPerPage = 5;
@@ -1097,72 +1213,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
} }
Widget _buildRecentAccess( Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
List<RecentAccessItem> items, final uniqueItems = view.uniqueItems;
List<DownloadHistoryItem> historyItems, final downloadItems = view.downloadItems;
ColorScheme colorScheme, final hasHiddenDownloads = view.hasHiddenDownloads;
) {
final albumGroups = <String, List<DownloadHistoryItem>>{};
for (final h in historyItems) {
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
? h.albumArtist!
: h.artistName;
final albumKey = '${h.albumName}|$artistForKey';
albumGroups.putIfAbsent(albumKey, () => []).add(h);
}
final downloadItems = <RecentAccessItem>[];
for (final entry in albumGroups.entries) {
final tracks = entry.value;
final mostRecent = tracks.reduce((a, b) =>
a.downloadedAt.isAfter(b.downloadedAt) ? a : b);
final artistForKey = (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
? mostRecent.albumArtist!
: mostRecent.artistName;
if (tracks.length == 1) {
downloadItems.add(RecentAccessItem(
id: mostRecent.spotifyId ?? mostRecent.id,
name: mostRecent.trackName,
subtitle: mostRecent.artistName,
imageUrl: mostRecent.coverUrl,
type: RecentAccessType.track,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
));
} else {
downloadItems.add(RecentAccessItem(
id: '${mostRecent.albumName}|$artistForKey',
name: mostRecent.albumName,
subtitle: artistForKey,
imageUrl: mostRecent.coverUrl,
type: RecentAccessType.album,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
));
}
}
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds));
final visibleDownloads = downloadItems
.where((item) => !hiddenIds.contains(item.id))
.take(10)
.toList();
final allItems = [...items, ...visibleDownloads];
allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
final seen = <String>{};
final uniqueItems = allItems.where((item) {
final key = '${item.type.name}:${item.id}';
if (seen.contains(key)) return false;
seen.add(key);
return true;
}).take(10).toList();
final hasHiddenDownloads = hiddenIds.isNotEmpty;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -2923,11 +2977,15 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
}, },
itemBuilder: (context, pageIndex) { itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * widget.itemsPerPage; final startIndex = pageIndex * widget.itemsPerPage;
final endIndex = (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length); final endIndex =
final pageItems = widget.section.items.sublist(startIndex, endIndex); (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length);
final pageItemCount = endIndex - startIndex;
return Column( return Column(
children: pageItems.map((item) => _buildQuickPickItem(item)).toList(), children: List.generate(pageItemCount, (index) {
final item = widget.section.items[startIndex + index];
return _buildQuickPickItem(item);
}),
); );
}, },
), ),
+36 -5
View File
@@ -783,11 +783,17 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text( child: Row(
'Downloading (${queueItems.length})', children: [
style: Theme.of(context).textTheme.titleMedium?.copyWith( Text(
fontWeight: FontWeight.bold, 'Downloading (${queueItems.length})',
), style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
_buildPauseResumeButton(context, ref, colorScheme),
],
), ),
), ),
), ),
@@ -1146,6 +1152,31 @@ if (queueItems.isEmpty &&
); );
} }
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( Widget _buildEmptyState(
BuildContext context, BuildContext context,
ColorScheme colorScheme, ColorScheme colorScheme,
@@ -276,6 +276,8 @@ class DownloadSettingsPage extends ConsumerWidget {
return 'Albums/Artist/[Year] Album/'; return 'Albums/Artist/[Year] Album/';
case 'year_album': case 'year_album':
return 'Albums/[Year] Album/'; return 'Albums/[Year] Album/';
case 'artist_album_singles':
return 'Artist/Album/ + Artist/Singles/';
default: default:
return 'Albums/Artist/Album Name/'; return 'Albums/Artist/Album Name/';
} }
@@ -328,6 +330,16 @@ class DownloadSettingsPage extends ConsumerWidget {
Navigator.pop(context); 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> { 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -296,19 +304,19 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
String message = error; String message = error;
if (message.contains('PlatformException')) { if (message.contains('PlatformException')) {
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message); final match = _platformExceptionPattern.firstMatch(message);
if (match != null) { if (match != null) {
message = match.group(1)?.trim() ?? message; message = match.group(1)?.trim() ?? message;
} else { } else {
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message); final simpleMatch = _platformExceptionSimplePattern.firstMatch(message);
if (simpleMatch != null) { if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message; message = simpleMatch.group(1)?.trim() ?? message;
} }
} }
} }
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), ''); message = message.replaceAll(_trailingNullsPattern, '');
message = message.replaceAll(RegExp(r'^\s*,\s*'), ''); message = message.replaceAll(_leadingCommaPattern, '');
return message; 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/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
final RegExp _domainPattern =
RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false);
class LogScreen extends StatefulWidget { class LogScreen extends StatefulWidget {
const LogScreen({super.key}); const LogScreen({super.key});
@@ -13,6 +16,7 @@ class LogScreen extends StatefulWidget {
} }
class _LogScreenState extends State<LogScreen> { class _LogScreenState extends State<LogScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _selectedLevel = 'ALL'; String _selectedLevel = 'ALL';
@@ -633,7 +637,7 @@ class _LogSummaryCard extends StatelessWidget {
combined.contains('connection refused')) { combined.contains('connection refused')) {
hasISPBlocking = true; hasISPBlocking = true;
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined); final domainMatch = _domainPattern.firstMatch(combined);
if (domainMatch != null) { if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!); blockedDomains.add(domainMatch.group(1)!);
} }
+144 -15
View File
@@ -25,14 +25,20 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> { class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false; bool _fileExists = false;
int? _fileSize; int? _fileSize;
String? _lyrics; String? _lyrics; // Cleaned lyrics for display (no timestamps)
String? _rawLyrics; // Raw LRC with timestamps for embedding
bool _lyricsLoading = false; bool _lyricsLoading = false;
String? _lyricsError; String? _lyricsError;
Color? _dominantColor; Color? _dominantColor;
bool _showTitleInAppBar = false; 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(); final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = static final RegExp _lrcTimestampPattern =
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
static final RegExp _lrcMetadataPattern =
RegExp(r'^\[[a-zA-Z]+:.*\]$');
static const List<String> _months = [ static const List<String> _months = [
'Jan', 'Jan',
'Feb', 'Feb',
@@ -844,18 +850,62 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
], ],
), ),
) )
else if (_lyrics != null) else if (_isInstrumental)
Container( Container(
constraints: const BoxConstraints(maxHeight: 300), padding: const EdgeInsets.all(16),
child: SingleChildScrollView( decoration: BoxDecoration(
child: Text( color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
_lyrics!, borderRadius: BorderRadius.circular(12),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( ),
color: colorScheme.onSurface, child: Row(
height: 1.6, 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 else
Center( Center(
@@ -877,26 +927,57 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
setState(() { setState(() {
_lyricsLoading = true; _lyricsLoading = true;
_lyricsError = null; _lyricsError = null;
_isInstrumental = false;
}); });
try { try {
// Convert duration from seconds to milliseconds // Convert duration from seconds to milliseconds
final durationMs = (item.duration ?? 0) * 1000; 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( final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '', item.spotifyId ?? '',
item.trackName, item.trackName,
item.artistName, item.artistName,
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first filePath: null, // Don't check file again
durationMs: durationMs, durationMs: durationMs,
).timeout( ).timeout(
const Duration(seconds: 20), const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout onTimeout: () => '',
); );
if (mounted) { if (mounted) {
if (result.isEmpty) { // Check for instrumental marker
if (result == '[instrumental:true]') {
setState(() {
_isInstrumental = true;
_lyricsLoading = false;
});
} else if (result.isEmpty) {
setState(() { setState(() {
_lyricsError = context.l10n.trackLyricsNotAvailable; _lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false; _lyricsLoading = false;
@@ -905,6 +986,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final cleanLyrics = _cleanLrcForDisplay(result); final cleanLyrics = _cleanLrcForDisplay(result);
setState(() { setState(() {
_lyrics = cleanLyrics; _lyrics = cleanLyrics;
_rawLyrics = result; // Keep raw LRC with timestamps for embedding
_lyricsEmbedded = false; // Lyrics from online, not embedded
_lyricsLoading = false; _lyricsLoading = false;
}); });
} }
@@ -921,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) { String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n'); final lines = lrc.split('\n');
final cleanLines = <String>[]; final cleanLines = <String>[];
for (final line in lines) { 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) { if (cleanLine.isNotEmpty) {
cleanLines.add(cleanLine); cleanLines.add(cleanLine);
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/logger.dart';
class CsvImportService { class CsvImportService {
static final _log = AppLogger('CsvImportService'); static final _log = AppLogger('CsvImportService');
static final RegExp _lineSplitPattern = RegExp(r'\r\n|\r|\n');
static Future<List<Track>> pickAndParseCsv({ static Future<List<Track>> pickAndParseCsv({
void Function(int current, int total)? onProgress, void Function(int current, int total)? onProgress,
@@ -123,7 +124,7 @@ class CsvImportService {
static List<Track> _parseCsv(String content) { static List<Track> _parseCsv(String content) {
final List<Track> tracks = []; final List<Track> tracks = [];
final lines = content.split(RegExp(r'\r\n|\r|\n')); final lines = content.split(_lineSplitPattern);
if (lines.isEmpty) return tracks; if (lines.isEmpty) return tracks;
int startIdx = 0; int startIdx = 0;
+113 -2
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -6,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('HistoryDatabase'); 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 /// SQLite database service for download history
/// Provides O(1) lookups by spotify_id and isrc with proper indexing /// Provides O(1) lookups by spotify_id and isrc with proper indexing
@@ -78,10 +83,115 @@ class HistoryDatabase {
// Future migrations go here // 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 /// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated /// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async { Future<bool> migrateFromSharedPreferences() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite'; final migrationKey = 'history_migrated_to_sqlite';
if (prefs.getBool(migrationKey) == true) { if (prefs.getBool(migrationKey) == true) {
@@ -153,6 +263,7 @@ class HistoryDatabase {
} }
/// Convert DB row (snake_case) to JSON format (camelCase) /// 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) { Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return { return {
'id': row['id'], 'id': row['id'],
@@ -161,7 +272,7 @@ class HistoryDatabase {
'albumName': row['album_name'], 'albumName': row['album_name'],
'albumArtist': row['album_artist'], 'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'], 'coverUrl': row['cover_url'],
'filePath': row['file_path'], 'filePath': _normalizeIosPath(row['file_path'] as String?),
'service': row['service'], 'service': row['service'],
'downloadedAt': row['downloaded_at'], 'downloadedAt': row['downloaded_at'],
'isrc': row['isrc'], 'isrc': row['isrc'],
+3 -2
View File
@@ -19,8 +19,9 @@ class PaletteService {
return null; return null;
} }
if (_colorCache.containsKey(imageUrl)) { final cached = _colorCache[imageUrl];
return _colorCache[imageUrl]; if (cached != null) {
return cached;
} }
try { try {
+8 -4
View File
@@ -9,6 +9,12 @@ class ShareIntentService {
factory ShareIntentService() => _instance; factory ShareIntentService() => _instance;
ShareIntentService._internal(); 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(); final _sharedUrlController = StreamController<String>.broadcast();
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription; StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
bool _initialized = false; bool _initialized = false;
@@ -57,14 +63,12 @@ class ShareIntentService {
String? _extractSpotifyUrl(String text) { String? _extractSpotifyUrl(String text) {
if (text.isEmpty) return null; 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) { if (uriMatch != null) {
return uriMatch.group(0); return uriMatch.group(0);
} }
final urlMatch = RegExp( final urlMatch = _spotifyUrlPattern.firstMatch(text);
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
).firstMatch(text);
if (urlMatch != null) { if (urlMatch != null) {
final fullUrl = urlMatch.group(0)!; final fullUrl = urlMatch.group(0)!;
final queryIndex = fullUrl.indexOf('?'); 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}) { List<LogEntry> filter({String? level, String? tag, String? search}) {
final tagLower = tag?.toLowerCase();
final searchLower = search?.toLowerCase();
return _entries.where((entry) { return _entries.where((entry) {
if (level != null && level != 'ALL' && entry.level != level) { if (level != null && level != 'ALL' && entry.level != level) {
return false; return false;
} }
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) { if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) {
return false; return false;
} }
if (search != null && search.isNotEmpty) { if (searchLower != null && searchLower.isNotEmpty) {
final searchLower = search.toLowerCase();
return entry.message.toLowerCase().contains(searchLower) || return entry.message.toLowerCase().contains(searchLower) ||
entry.tag.toLowerCase().contains(searchLower) || entry.tag.toLowerCase().contains(searchLower) ||
(entry.error?.toLowerCase().contains(searchLower) ?? false); (entry.error?.toLowerCase().contains(searchLower) ?? false);
+17 -8
View File
@@ -26,6 +26,15 @@ class _UpdateDialogState extends State<UpdateDialog> {
bool _isDownloading = false; bool _isDownloading = false;
double _progress = 0; double _progress = 0;
String _statusText = ''; 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 { Future<void> _downloadAndInstall() async {
final apkUrl = widget.updateInfo.apkDownloadUrl; final apkUrl = widget.updateInfo.apkDownloadUrl;
@@ -293,12 +302,12 @@ class _UpdateDialogState extends State<UpdateDialog> {
String _formatChangelog(String changelog) { String _formatChangelog(String changelog) {
var content = 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) { if (whatsNewMatch != null) {
content = content.substring(whatsNewMatch.end); 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) { if (cutoffMatch != null) {
content = content.substring(0, cutoffMatch.start); content = content.substring(0, cutoffMatch.start);
} }
@@ -310,7 +319,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
line = line.trim(); line = line.trim();
if (line.isEmpty) continue; if (line.isEmpty) continue;
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line); final sectionMatch = _sectionPattern.firstMatch(line);
if (sectionMatch != null) { if (sectionMatch != null) {
final section = sectionMatch.group(1)?.trim(); final section = sectionMatch.group(1)?.trim();
if (section != null && section.isNotEmpty) { if (section != null && section.isNotEmpty) {
@@ -320,19 +329,19 @@ class _UpdateDialogState extends State<UpdateDialog> {
continue; continue;
} }
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line); final listMatch = _listPattern.firstMatch(line);
if (listMatch != null) { if (listMatch != null) {
var itemText = listMatch.group(1) ?? ''; var itemText = listMatch.group(1) ?? '';
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? ''); itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? '');
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? ''); itemText = itemText.replaceAllMapped(_codePattern, (m) => m.group(1) ?? '');
formattedLines.add('$itemText'); formattedLines.add('$itemText');
continue; continue;
} }
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line); final subListMatch = _subListPattern.firstMatch(line);
if (subListMatch != null) { if (subListMatch != null) {
var itemText = subListMatch.group(1) ?? ''; 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'); formattedLines.add(' - $itemText');
continue; continue;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.2.0+63 version: 3.2.1+64
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.2.0+63 version: 3.2.1+64
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0