Compare commits

...

37 Commits

Author SHA1 Message Date
zarzet 61720f3f2a chore(ios): sync FFmpeg service and add palette_generator dependency 2026-01-19 02:55:39 +07:00
zarzet 7749399239 docs: add translator credits to changelog 2026-01-19 02:41:57 +07:00
zarzet d143b82068 fix: add es_ES and pt_PT locale codes to language selector 2026-01-19 02:33:12 +07:00
zarzet 606e7c1079 fix: change translator links from GitHub to Crowdin profiles 2026-01-19 02:28:35 +07:00
zarzet a650632c4e feat: add translators section in about page and fix ARB locale format 2026-01-19 02:25:30 +07:00
zarzet 3c118f74e4 chore: rename ARB files and add Spanish/Portuguese languages 2026-01-19 02:17:32 +07:00
zarzet bc3055f6e1 chore: update supported locales 2026-01-19 02:14:54 +07:00
zarzet 7c86ae0b7e feat: add quick search provider switcher and genre/label for extensions
- Add dropdown menu in search bar for instant provider switching
- Support genre & label metadata for extension downloads
- Bump version to 3.1.2 (build 61)
2026-01-19 02:14:52 +07:00
zarzet 595bfb2711 feat: add button setting type for extension actions
- Add SettingTypeButton for action buttons in extension settings
- Add Action field to ExtensionSetting for JS function name
- Update extension detail page UI to render button settings
- Add InvokeAction method to execute button actions
2026-01-19 02:14:52 +07:00
zarzet 5f39a3d52f fix: use CollapseMode.none for smoother header animation 2026-01-19 02:14:50 +07:00
zarzet e7077781e6 feat: add genre and label metadata to FLAC downloads
- Fetch genre and label from Deezer album API before download
- Add GENRE, ORGANIZATION (label), and COPYRIGHT tags to FLAC files
- Update Go Metadata struct with new fields
- Add GetDeezerExtendedMetadata export function for Flutter
- Register platform channel handlers for Android and iOS
- Pass genre/label through download flow to all services (Tidal/Qobuz/Amazon)
2026-01-19 02:14:50 +07:00
zarzet 42d15db4ca fix: show 'Artist' label for artist items instead of 'Album'
Fixed fallback subtitle in _CollectionItemWidget for artist search results
2026-01-19 02:14:49 +07:00
zarzet c2599981d6 fix: Clear All now hides ALL downloads, not just visible 10
Previously only hid uniqueItems (max 10 visible), now hides all downloadItems
2026-01-19 02:14:48 +07:00
zarzet a1647a41ff fix: use ref.watch for hiddenDownloadIds reactivity
Show All Downloads button now updates immediately without restart
2026-01-19 02:14:47 +07:00
zarzet bf2fc7702b chore: remove debug print statements from recent_access_provider 2026-01-19 02:14:46 +07:00
zarzet f814408702 style: reduce AppBar title font size to 16px for long titles 2026-01-19 02:14:45 +07:00
zarzet 6b1958bfd0 feat: show 'Show All Downloads' button when recents is empty
- Button appears when all items are cleared/hidden
- Clicking resets hidden downloads list
- Clear All button only shows when there are items
- Empty state with visibility_off icon
2026-01-19 02:14:29 +07:00
zarzet bc120ffa76 feat: allow hiding downloads from recents without deleting files
- Add hiddenDownloadIds set to RecentAccessState
- X button on download items hides from recents (not delete file)
- Hidden IDs persisted in SharedPreferences
- Clear All also clears hidden downloads list
- Single track shows as Track, 2+ tracks shows as Album in recents
2026-01-19 02:14:27 +07:00
zarzet 5ea454a0b0 fix: downloaded album navigation from recents 2026-01-19 02:14:26 +07:00
zarzet da574f895c feat: v3.1.2 - MP3 option, dominant color headers, sticky titles, disc separation
Added:
- MP3 quality option with FLAC-to-MP3 conversion (320kbps)
- Dominant color header backgrounds on detail screens
- Spotify-style sticky title on scroll (album, playlist, artist screens)
- Disc separation for multi-disc albums
- Album grouping in recent downloads
- 50% screen width cover art

Changed:
- Improved FFmpeg FLAC-to-MP3 conversion workflow
- AppBar uses theme surface color when collapsed

Fixed:
- Empty catch blocks with proper comments
- Russian plural forms (ICU syntax)

Dependencies:
- Added palette_generator ^0.3.3+4
2026-01-19 02:13:53 +07:00
Zarz Eleutherius 1c445e91d9 Merge pull request #77 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 02:12:44 +07:00
Zarz Eleutherius 5d03eb0656 New translations app_en.arb (Portuguese) 2026-01-19 02:11:51 +07:00
Zarz Eleutherius becb6845a6 Merge pull request #68 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 00:48:32 +07:00
Zarz Eleutherius be3ee3b216 New translations app_en.arb (Chinese Traditional) 2026-01-19 00:29:39 +07:00
Zarz Eleutherius 3747674968 New translations app_en.arb (Russian) 2026-01-19 00:29:37 +07:00
Zarz Eleutherius ff9d088c5f New translations app_en.arb (German) 2026-01-19 00:29:34 +07:00
Zarz Eleutherius 12db11d559 New translations app_en.arb (Spanish) 2026-01-19 00:29:33 +07:00
Zarz Eleutherius 7e1aca33a5 New translations app_en.arb (Hindi) 2026-01-18 03:42:29 +07:00
Zarz Eleutherius 07a1c68354 New translations app_en.arb (Indonesian) 2026-01-18 03:42:28 +07:00
Zarz Eleutherius f4d7c6531f New translations app_en.arb (Chinese Traditional) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius e9ca054682 New translations app_en.arb (Chinese Simplified) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius 1069bdd0d8 New translations app_en.arb (Portuguese) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius ff882a58d7 New translations app_en.arb (Dutch) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius dddc8c3d94 New translations app_en.arb (Korean) 2026-01-18 03:42:24 +07:00
Zarz Eleutherius 720525b67b New translations app_en.arb (German) 2026-01-18 03:42:22 +07:00
Zarz Eleutherius cc12f63d36 New translations app_en.arb (Spanish) 2026-01-18 03:42:21 +07:00
Zarz Eleutherius 5c67553596 New translations app_en.arb (French) 2026-01-18 03:42:20 +07:00
63 changed files with 12460 additions and 508 deletions
+109
View File
@@ -1,5 +1,114 @@
# Changelog # Changelog
## [3.1.2] - 2026-01-19
### Added
- **New Languages**: Added Spanish (es) and Portuguese (pt) translations
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
- Tap the search icon to reveal a dropdown menu with all available search providers
- Shows default provider (Deezer based on metadata source setting) at the top
- Lists all enabled extensions with custom search capability
- Displays extension icons when available
- Checkmark indicates currently selected provider
- Search hint text updates immediately when switching providers
- Re-triggers search automatically if there's existing text in the search bar
- Eliminates need to navigate to Settings > Extensions > Search Provider
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
- Extensions can define `button` type in manifest settings
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
- Useful for authentication, manual sync, or any custom action
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
- Fetches genre and label from Deezer album API for each track
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
- Works automatically when Deezer track ID is available (via ISRC matching)
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
- Available in both the quality picker dialog and default quality settings
- Works with all services (Tidal, Qobuz, Amazon) and extensions
- **MP3 Metadata Embedding**: Full metadata support for MP3 files
- Cover art embedded using ID3v2 tags
- Synced lyrics embedded (fetched from lrclib.net)
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
- Extracts dominant color from cover art using `palette_generator`
- Creates a gradient from dominant color to theme surface color
- Smooth 500ms color transition animation
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
- More prominent album artwork display
- Larger shadow and rounded corners (20px radius)
- Higher resolution cover caching
- **Sticky Title**: Title appears in AppBar when scrolling past the info card
- Smooth fade-in animation (200ms) when scrolling down
- Title hidden when header is expanded (shows in info card instead)
- AppBar uses theme color (surface) for clean, native look
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
- Extracted from first track's artist metadata
- Styled with `onSurfaceVariant` color for visual hierarchy
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
- Tracks sorted by disc number first, then by track number
- Single-disc albums display normally without separators
- Fixes confusion when albums have duplicate track numbers across discs
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
- Prevents flooding the recents list when downloading full albums
- Groups tracks by album name and artist
- Tapping navigates directly to the downloaded album screen
- Shows the most recent download time for each album
### Changed
- **FFmpeg FLAC-to-MP3 Conversion**: Improved conversion process
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
- Original FLAC file automatically deleted after successful conversion
- New `embedMetadataToMp3()` method for MP3-specific tag embedding
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
- Dark theme: Black background with white text
- Light theme: White background with black text
- Matches modern app behavior for better readability
### Fixed
- **MP3 Quality Display in Track Metadata**: Fixed incorrect quality display for MP3 files
- MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate
- History no longer stores FLAC audio specs for converted MP3 files
- Both File Info badges and metadata grid show correct MP3 quality
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
- Removed redundant `=1` clauses that were overriding `one` plural category
- Affected 10 plural strings including track counts and delete confirmations
- Plurals now correctly handle Russian grammar (1 трек, 2 трека, 5 треков)
### Dependencies
- Added `palette_generator: ^0.3.3+4` for cover art color extraction
---
## [3.1.1] - 2026-01-17 ## [3.1.1] - 2026-01-17
### Added ### Added
@@ -284,6 +284,13 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) result.success(response)
} }
"getDeezerExtendedMetadata" -> {
val trackId = call.argument<String>("track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerExtendedMetadata(trackId)
}
result.success(response)
}
"convertSpotifyToDeezer" -> { "convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: "" val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: "" val spotifyId = call.argument<String>("spotify_id") ?: ""
@@ -438,6 +445,14 @@ class MainActivity: FlutterActivity() {
} }
result.success(null) result.success(null)
} }
"invokeExtensionAction" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val actionName = call.argument<String>("action") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
}
result.success(response)
}
"searchTracksWithExtensions" -> { "searchTracksWithExtensions" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20 val limit = call.argument<Int>("limit") ?: 20
+134 -7
View File
@@ -42,17 +42,27 @@ class FFmpegServiceIOS {
} }
/// Convert FLAC to MP3 /// Convert FLAC to MP3
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async { /// If deleteOriginal is true, deletes the FLAC file after conversion
final dir = File(inputPath).parent.path; static Future<String?> convertFlacToMp3(
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', ''); String inputPath, {
final outputDir = '$dir${Platform.pathSeparator}MP3'; String bitrate = '320k',
await Directory(outputDir).create(recursive: true); bool deleteOriginal = true,
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3'; }) async {
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command); final result = await _execute(command);
if (result.success) return outputPath; if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('FLAC to MP3 conversion failed: ${result.output}'); _log.e('FLAC to MP3 conversion failed: ${result.output}');
return null; return null;
} }
@@ -177,6 +187,123 @@ class FFmpegServiceIOS {
return null; return null;
} }
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempOutput = '$mp3Path.tmp';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
try {
await File(mp3Path).delete();
await File(tempOutput).rename(mp3Path);
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
/// Check if FFmpeg is available /// Check if FFmpeg is available
static Future<bool> isAvailable() async { static Future<bool> isAvailable() async {
try { try {
+3
View File
@@ -564,6 +564,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum, DiscNumber: actualDiscNum,
ISRC: req.ISRC, ISRC: req.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
} }
// Use cover data from parallel fetch // Use cover data from parallel fetch
+107 -9
View File
@@ -132,16 +132,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
} }
} }
type deezerGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type deezerAlbumFull struct { type deezerAlbumFull struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Cover string `json:"cover"` Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"` // album, single, ep, compile
Label string `json:"label"` // Record label name
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
Tracks struct { Tracks struct {
@@ -310,12 +319,23 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ") artistName = strings.Join(names, ", ")
} }
// Extract genres as comma-separated string
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
genreStr := strings.Join(genres, ", ")
info := AlbumInfoMetadata{ info := AlbumInfoMetadata{
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
Name: album.Title, Name: album.Title,
ReleaseDate: album.ReleaseDate, ReleaseDate: album.ReleaseDate,
Artists: artistName, Artists: artistName,
Images: albumImage, Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
} }
// Fetch ISRCs in parallel // Fetch ISRCs in parallel
@@ -677,6 +697,84 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
return album.Cover return album.Cover
} }
// AlbumExtendedMetadata contains genre and label information from an album
type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres
Label string // Record label name
}
// GetAlbumExtendedMetadata fetches genre and label from a Deezer album
// Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" {
return nil, fmt.Errorf("empty album ID")
}
// Check cache first
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumExtendedMetadata), nil
}
c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, fmt.Errorf("failed to fetch album: %w", err)
}
// Extract genres as comma-separated string
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
}
// Cache the result
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
}
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
return result, nil
}
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
var track deezerTrack
if err := c.getJSON(ctx, trackURL, &track); err != nil {
return "", err
}
return fmt.Sprintf("%d", track.Album.ID), nil
}
// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID
// This is a convenience function that first gets the album ID, then fetches album metadata
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
albumID, err := c.GetTrackAlbumID(ctx, trackID)
if err != nil {
return nil, fmt.Errorf("failed to get album ID: %w", err)
}
return c.GetAlbumExtendedMetadata(ctx, albumID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
+52
View File
@@ -153,6 +153,10 @@ type DownloadRequest struct {
ItemID string `json:"item_id"` // Unique ID for progress tracking ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
// Extended metadata from Deezer for FLAC tagging
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
Label string `json:"label,omitempty"` // Record label name
Copyright string `json:"copyright,omitempty"` // Copyright information
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch // Enriched IDs from Odesli/song.link - used to skip search and directly fetch
TidalID string `json:"tidal_id,omitempty"` TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"`
@@ -837,6 +841,37 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
// trackID: Deezer track ID (will look up album ID from track)
// Returns JSON with genre, label fields
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
if err != nil {
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SearchDeezerByISRC searches for a track by ISRC on Deezer // SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) { func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -1290,6 +1325,23 @@ func CleanupExtensions() {
manager.UnloadAllExtensions() manager.UnloadAllExtensions()
} }
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
manager := GetExtensionManager()
result, err := manager.InvokeAction(extensionID, actionName)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ==================== EXTENSION AUTH API ==================== // ==================== EXTENSION AUTH API ====================
// GetExtensionPendingAuthJSON returns pending auth request for an extension // GetExtensionPendingAuthJSON returns pending auth request for an extension
+57
View File
@@ -959,3 +959,60 @@ func (m *ExtensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
// InvokeAction calls a custom action function on an extension (e.g., for button settings)
// The function is called as extension.<actionName>() and can return a result
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
}
if !ext.Enabled {
return nil, fmt.Errorf("extension is disabled")
}
// Call the action function on the extension object
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
if err != nil {
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err)
}
if result == nil || goja.IsUndefined(result) {
return map[string]interface{}{"success": true}, nil
}
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
return resultMap, nil
}
return map[string]interface{}{"success": true, "result": exported}, nil
}
+11
View File
@@ -23,6 +23,7 @@ const (
SettingTypeNumber SettingType = "number" SettingTypeNumber SettingType = "number"
SettingTypeBool SettingType = "boolean" SettingTypeBool SettingType = "boolean"
SettingTypeSelect SettingType = "select" SettingTypeSelect SettingType = "select"
SettingTypeButton SettingType = "button" // Action button that calls a JS function
) )
// ExtensionPermissions defines what resources an extension can access // ExtensionPermissions defines what resources an extension can access
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
Secret bool `json:"secret,omitempty"` Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type Options []string `json:"options,omitempty"` // For select type
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
} }
// QualityOption represents a quality option for download providers // QualityOption represents a quality option for download providers
@@ -204,6 +206,7 @@ func (m *ExtensionManifest) Validate() error {
SettingTypeNumber: true, SettingTypeNumber: true,
SettingTypeBool: true, SettingTypeBool: true,
SettingTypeSelect: true, SettingTypeSelect: true,
SettingTypeButton: true,
} }
if !validTypes[setting.Type] { if !validTypes[setting.Type] {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -219,6 +222,14 @@ func (m *ExtensionManifest) Validate() error {
Message: "select type requires options", Message: "select type requires options",
} }
} }
// Button type requires action
if setting.Type == SettingTypeButton && setting.Action == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].action", i),
Message: "button type requires action (JS function name)",
}
}
} }
return nil return nil
+18
View File
@@ -797,6 +797,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Service: req.Source, Service: req.Source,
} }
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment, copy metadata // If extension has skipMetadataEnrichment, copy metadata
if ext.Manifest.SkipMetadataEnrichment { if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true resp.SkipMetadataEnrichment = true
@@ -937,6 +946,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Service: providerID, Service: providerID,
} }
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment and returned metadata, use it // If extension has skipMetadataEnrichment and returned metadata, use it
if ext.Manifest.SkipMetadataEnrichment { if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true resp.SkipMetadataEnrichment = true
+74
View File
@@ -24,6 +24,9 @@ type Metadata struct {
ISRC string ISRC string
Description string Description string
Lyrics string Lyrics string
Genre string // Music genre (e.g., "Rock", "Pop", "Electronic")
Label string // Record label (ORGANIZATION tag in Vorbis)
Copyright string // Copyright information
} }
// EmbedMetadata embeds metadata into a FLAC file // EmbedMetadata embeds metadata into a FLAC file
@@ -82,6 +85,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
} }
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -180,6 +195,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
} }
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -348,6 +375,53 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation
// This is used for extension downloads where the file is already downloaded
func EmbedGenreLabel(filePath string, genre, label string) error {
if genre == "" && label == "" {
return nil // Nothing to embed
}
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
if genre != "" {
setComment(cmt, "GENRE", genre)
}
if label != "" {
setComment(cmt, "ORGANIZATION", label)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
return f.Save(filePath)
}
// ExtractLyrics extracts embedded lyrics from a FLAC file // ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) { func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
+3
View File
@@ -1120,6 +1120,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC, ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
} }
var coverData []byte var coverData []byte
+3
View File
@@ -182,6 +182,9 @@ type AlbumInfoMetadata struct {
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
Artists string `json:"artists"` Artists string `json:"artists"`
Images string `json:"images"` Images string `json:"images"`
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
Label string `json:"label,omitempty"` // Record label name
Copyright string `json:"copyright,omitempty"` // Copyright information
} }
// AlbumResponsePayload is the response for album requests // AlbumResponsePayload is the response for album requests
+3
View File
@@ -1716,6 +1716,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
ISRC: track.ISRC, // Use actual ISRC from Tidal ISRC: track.ISRC, // Use actual ISRC from Tidal
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
} }
var coverData []byte var coverData []byte
+15
View File
@@ -227,6 +227,13 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "getDeezerExtendedMetadata":
let args = call.arguments as! [String: Any]
let trackId = args["track_id"] as! String
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
if let error = error { throw error }
return response
case "convertSpotifyToDeezer": case "convertSpotifyToDeezer":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String let resourceType = args["resource_type"] as! String
@@ -375,6 +382,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return nil return nil
case "invokeExtensionAction":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let actionName = args["action"] as! String
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
if let error = error { throw error }
return response
case "searchTracksWithExtensions": case "searchTracksWithExtensions":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
+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.1.1'; static const String version = '3.1.2';
static const String buildNumber = '60'; static const String buildNumber = '61';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+60
View File
@@ -107,6 +107,7 @@ abstract class AppLocalizations {
Locale('de'), Locale('de'),
Locale('en'), Locale('en'),
Locale('es'), Locale('es'),
Locale('es', 'ES'),
Locale('fr'), Locale('fr'),
Locale('hi'), Locale('hi'),
Locale('id'), Locale('id'),
@@ -114,6 +115,7 @@ abstract class AppLocalizations {
Locale('ko'), Locale('ko'),
Locale('nl'), Locale('nl'),
Locale('pt'), Locale('pt'),
Locale('pt', 'PT'),
Locale('ru'), Locale('ru'),
Locale('zh'), Locale('zh'),
Locale('zh', 'CN'), Locale('zh', 'CN'),
@@ -816,6 +818,12 @@ abstract class AppLocalizations {
/// **'The talented artist who created our beautiful app logo!'** /// **'The talented artist who created our beautiful app logo!'**
String get aboutLogoArtist; String get aboutLogoArtist;
/// Section for translators
///
/// In en, this message translates to:
/// **'Translators'**
String get aboutTranslators;
/// Section for special thanks /// Section for special thanks
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3252,6 +3260,36 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'** /// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle; String get qualityHiResFlacMaxSubtitle;
/// Quality option - MP3 lossy format
///
/// In en, this message translates to:
/// **'MP3'**
String get qualityMp3;
/// Technical spec for MP3
///
/// In en, this message translates to:
/// **'320kbps (converted from FLAC)'**
String get qualityMp3Subtitle;
/// Setting - enable MP3 quality option
///
/// In en, this message translates to:
/// **'Enable MP3 Option'**
String get enableMp3Option;
/// Subtitle when MP3 is enabled
///
/// In en, this message translates to:
/// **'MP3 quality option is available'**
String get enableMp3OptionSubtitleOn;
/// Subtitle when MP3 is disabled
///
/// In en, this message translates to:
/// **'Downloads FLAC then converts to 320kbps MP3'**
String get enableMp3OptionSubtitleOff;
/// Note about quality availability /// Note about quality availability
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3588,6 +3626,12 @@ abstract class AppLocalizations {
/// **'Select tracks to delete'** /// **'Select tracks to delete'**
String get downloadedAlbumSelectToDelete; String get downloadedAlbumSelectToDelete;
/// Header for disc separator in multi-disc albums
///
/// In en, this message translates to:
/// **'Disc {discNumber}'**
String downloadedAlbumDiscHeader(int discNumber);
/// Extension capability - utility functions /// Extension capability - utility functions
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3663,6 +3707,22 @@ class _AppLocalizationsDelegate
AppLocalizations lookupAppLocalizations(Locale locale) { AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when language+country codes are specified. // Lookup logic when language+country codes are specified.
switch (locale.languageCode) { switch (locale.languageCode) {
case 'es':
{
switch (locale.countryCode) {
case 'ES':
return AppLocalizationsEsEs();
}
break;
}
case 'pt':
{
switch (locale.countryCode) {
case 'PT':
return AppLocalizationsPtPt();
}
break;
}
case 'zh': case 'zh':
{ {
switch (locale.countryCode) { switch (locale.countryCode) {
+43 -16
View File
@@ -115,7 +115,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsTitle => 'Einstellungen'; String get settingsTitle => 'Einstellungen';
@override @override
String get settingsDownload => 'Download'; String get settingsDownload => 'Herunterladen';
@override @override
String get settingsAppearance => 'Erscheinungsbild'; String get settingsAppearance => 'Erscheinungsbild';
@@ -130,7 +130,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsAbout => 'Über'; String get settingsAbout => 'Über';
@override @override
String get downloadTitle => 'Download'; String get downloadTitle => 'Herunterladen';
@override @override
String get downloadLocation => 'Download-Speicherort'; String get downloadLocation => 'Download-Speicherort';
@@ -410,40 +410,46 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Besonderer Dank';
@override @override
String get aboutLinks => 'Links'; String get aboutLinks => 'Links';
@override @override
String get aboutMobileSource => 'Mobile source code'; String get aboutMobileSource => 'Mobiler Quellcode';
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => 'PC Quellcode';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => 'Problem melden';
@override @override
String get aboutReportIssueSubtitle => 'Report any problems you encounter'; String get aboutReportIssueSubtitle =>
'Melde jedes Problem, die dir auftreten';
@override @override
String get aboutFeatureRequest => 'Feature request'; String get aboutFeatureRequest => 'Feature vorschlagen';
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; String get aboutFeatureRequestSubtitle =>
'Schlage neue Funktionen für die App vor';
@override @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@override @override
String get aboutBuyMeCoffee => 'Buy me a coffee'; String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
@override @override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; String get aboutBuyMeCoffeeSubtitle =>
'Unterstütze die Entwicklung auf Ko-fi';
@override @override
String get aboutApp => 'App'; String get aboutApp => 'App';
@@ -453,25 +459,25 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutBinimumDesc => String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; 'Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!';
@override @override
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@override @override
String get aboutDoubleDoubleDesc => String get aboutDoubleDoubleDesc =>
'Amazing API for Amazon Music downloads. Thank you for making it free!'; 'Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!';
@override @override
String get aboutDabMusic => 'DAB Music'; String get aboutDabMusic => 'DAB Music';
@override @override
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
@@ -1792,6 +1798,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1983,6 +2005,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
File diff suppressed because it is too large Load Diff
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -406,6 +406,9 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'Seniman berbakat yang membuat logo aplikasi kita yang indah!'; 'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Terima Kasih Khusus'; String get aboutSpecialThanks => 'Terima Kasih Khusus';
@@ -1794,6 +1797,22 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)';
@override
String get enableMp3Option => 'Aktifkan Opsi MP3';
@override
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia';
@override
String get enableMp3OptionSubtitleOff =>
'Unduh FLAC lalu konversi ke MP3 320kbps';
@override @override
String get qualityNote => String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@@ -1986,6 +2005,11 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus'; String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Fungsi Utilitas'; String get utilityFunctions => 'Fungsi Utilitas';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsJa extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'スペシャルサンクス'; String get aboutSpecialThanks => 'スペシャルサンクス';
@@ -1782,6 +1785,22 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
File diff suppressed because it is too large Load Diff
+36 -12
View File
@@ -74,9 +74,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count треков', other: '$count треков',
one: '1 трек',
many: '$count треков', many: '$count треков',
few: '$count трека', few: '$count трека',
one: '$count трек',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -87,9 +87,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count альбомов', other: '$count альбомов',
one: '1 альбом',
many: '$count альбомов', many: '$count альбомов',
few: '$count альбома', few: '$count альбома',
one: '$count альбом',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -414,6 +414,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'Талантливый художник, который создал наш красивый логотип приложения!'; 'Талантливый художник, который создал наш красивый логотип приложения!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Особая благодарность'; String get aboutSpecialThanks => 'Особая благодарность';
@@ -489,9 +492,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count треков', other: '$count треков',
one: '1 трек',
many: '$count треков', many: '$count треков',
few: '$count трека', few: '$count трека',
one: '$count трек',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -523,9 +526,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count релизов', other: '$count релизов',
one: '1 релиз',
many: '$count релизов', many: '$count релизов',
few: '$count релиза', few: '$count релиза',
one: '$count релиз',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -901,9 +904,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.'; return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
} }
@@ -946,9 +949,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалено $count $_temp0'; return 'Удалено $count $_temp0';
} }
@@ -1095,9 +1098,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0'; return 'Удалить $count $_temp0';
} }
@@ -1510,9 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count треков', other: '$count треков',
one: '1 трек',
many: '$count треков', many: '$count треков',
few: '$count трека', few: '$count трека',
one: '$count трек',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -1533,7 +1536,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackFileInfo => 'Информация о файле'; String get trackFileInfo => 'Информация о файле';
@override @override
String get trackLyrics => 'Тексты песен'; String get trackLyrics => 'Текст песни';
@override @override
String get trackFileNotFound => 'Файл не найден'; String get trackFileNotFound => 'Файл не найден';
@@ -1545,7 +1548,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackOpenInSpotify => 'Открыть в Spotify'; String get trackOpenInSpotify => 'Открыть в Spotify';
@override @override
String get trackTrackName => 'Название трека'; String get trackTrackName => 'Название';
@override @override
String get trackArtist => 'Исполнитель'; String get trackArtist => 'Исполнитель';
@@ -1820,6 +1823,22 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе'; 'Фактическое качество зависит от доступности треков в сервисе';
@@ -1976,9 +1995,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
} }
@@ -2008,9 +2027,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0'; return 'Удалить $count $_temp0';
} }
@@ -2018,6 +2037,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления'; String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Функции утилиты'; String get utilityFunctions => 'Функции утилиты';
+25 -1
View File
@@ -402,6 +402,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get aboutLogoArtist => String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!'; 'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Utility Functions';
@@ -4035,7 +4059,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@override @override
String get homeRecent => 'Recent'; String get homeRecent => '最新的';
@override @override
String get historyTitle => 'History'; String get historyTitle => 'History';
+70 -32
View File
@@ -1,6 +1,6 @@
{ {
"@@locale": "de", "@@locale": "de",
"@@last_modified": "2026-01-17", "@@last_modified": "2026-01-16",
"appName": "SpotiFLAC", "appName": "SpotiFLAC",
"@appName": { "@appName": {
"description": "App name - DO NOT TRANSLATE" "description": "App name - DO NOT TRANSLATE"
@@ -131,7 +131,7 @@
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
}, },
"settingsDownload": "Download", "settingsDownload": "Herunterladen",
"@settingsDownload": { "@settingsDownload": {
"description": "Settings section - download options" "description": "Settings section - download options"
}, },
@@ -151,7 +151,7 @@
"@settingsAbout": { "@settingsAbout": {
"description": "Settings section - app info" "description": "Settings section - app info"
}, },
"downloadTitle": "Download", "downloadTitle": "Herunterladen",
"@downloadTitle": { "@downloadTitle": {
"description": "Download settings page title" "description": "Download settings page title"
}, },
@@ -508,11 +508,11 @@
"@aboutOriginalCreator": { "@aboutOriginalCreator": {
"description": "Role description for original creator" "description": "Role description for original creator"
}, },
"aboutLogoArtist": "The talented artist who created our beautiful app logo!", "aboutLogoArtist": "Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!",
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Besonderer Dank",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
}, },
@@ -520,27 +520,27 @@
"@aboutLinks": { "@aboutLinks": {
"description": "Section for external links" "description": "Section for external links"
}, },
"aboutMobileSource": "Mobile source code", "aboutMobileSource": "Mobiler Quellcode",
"@aboutMobileSource": { "@aboutMobileSource": {
"description": "Link to mobile GitHub repo" "description": "Link to mobile GitHub repo"
}, },
"aboutPCSource": "PC source code", "aboutPCSource": "PC Quellcode",
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutReportIssue": "Report an issue", "aboutReportIssue": "Problem melden",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
}, },
"aboutReportIssueSubtitle": "Report any problems you encounter", "aboutReportIssueSubtitle": "Melde jedes Problem, die dir auftreten",
"@aboutReportIssueSubtitle": { "@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue" "description": "Subtitle for report issue"
}, },
"aboutFeatureRequest": "Feature request", "aboutFeatureRequest": "Feature vorschlagen",
"@aboutFeatureRequest": { "@aboutFeatureRequest": {
"description": "Link to suggest features" "description": "Link to suggest features"
}, },
"aboutFeatureRequestSubtitle": "Suggest new features for the app", "aboutFeatureRequestSubtitle": "Schlage neue Funktionen für die App vor",
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
@@ -548,11 +548,11 @@
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
}, },
"aboutBuyMeCoffee": "Buy me a coffee", "aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
"@aboutBuyMeCoffee": { "@aboutBuyMeCoffee": {
"description": "Donation link" "description": "Donation link"
}, },
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi", "aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
"@aboutBuyMeCoffeeSubtitle": { "@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation" "description": "Subtitle for donation"
}, },
@@ -564,11 +564,11 @@
"@aboutVersion": { "@aboutVersion": {
"description": "Version info label" "description": "Version info label"
}, },
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", "aboutBinimumDesc": "Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!",
"@aboutBinimumDesc": { "@aboutBinimumDesc": {
"description": "Credit description for binimum" "description": "Credit description for binimum"
}, },
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", "aboutSachinsenalDesc": "Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!",
"@aboutSachinsenalDesc": { "@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64" "description": "Credit description for sachinsenal0x64"
}, },
@@ -576,7 +576,7 @@
"@aboutDoubleDouble": { "@aboutDoubleDouble": {
"description": "Name of Amazon API service - DO NOT TRANSLATE" "description": "Name of Amazon API service - DO NOT TRANSLATE"
}, },
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", "aboutDoubleDoubleDesc": "Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!",
"@aboutDoubleDoubleDesc": { "@aboutDoubleDoubleDesc": {
"description": "Credit for DoubleDouble API" "description": "Credit for DoubleDouble API"
}, },
@@ -584,7 +584,7 @@
"@aboutDabMusic": { "@aboutDabMusic": {
"description": "Name of Qobuz API service - DO NOT TRANSLATE" "description": "Name of Qobuz API service - DO NOT TRANSLATE"
}, },
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", "aboutDabMusicDesc": "Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!",
"@aboutDabMusicDesc": { "@aboutDabMusicDesc": {
"description": "Credit for DAB Music API" "description": "Credit for DAB Music API"
}, },
@@ -642,6 +642,20 @@
} }
} }
}, },
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info", "trackMetadataTitle": "Track Info",
"@trackMetadataTitle": { "@trackMetadataTitle": {
"description": "Track metadata screen title" "description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
}, },
"sectionLanguage": "Language", "sectionLanguage": "Language",
"@sectionLanguage": { "@sectionLanguage": {
"description": "Settings section header for language selection" "description": "Settings section header for language"
}, },
"appearanceLanguage": "App Language", "appearanceLanguage": "App Language",
"@appearanceLanguage": { "@appearanceLanguage": {
"description": "Setting title for language selection" "description": "Language setting title"
}, },
"appearanceLanguageSubtitle": "Choose your preferred language", "appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": { "@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting" "description": "Language setting subtitle"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
}, },
"settingsAppearanceSubtitle": "Theme, colors, display", "settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": { "@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
} }
} }
+19
View File
@@ -290,6 +290,8 @@
"@aboutOriginalCreator": {"description": "Role description for original creator"}, "@aboutOriginalCreator": {"description": "Role description for original creator"},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!", "aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"@aboutLogoArtist": {"description": "Role description for logo artist"}, "@aboutLogoArtist": {"description": "Role description for logo artist"},
"aboutTranslators": "Translators",
"@aboutTranslators": {"description": "Section for translators"},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {"description": "Section for special thanks"}, "@aboutSpecialThanks": {"description": "Section for special thanks"},
"aboutLinks": "Links", "aboutLinks": "Links",
@@ -1320,6 +1322,16 @@
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"}, "@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"}, "@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
"qualityMp3": "MP3",
"@qualityMp3": {"description": "Quality option - MP3 lossy format"},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"}, "@qualityNote": {"description": "Note about quality availability"},
@@ -1459,6 +1471,13 @@
}, },
"downloadedAlbumSelectToDelete": "Select tracks to delete", "downloadedAlbumSelectToDelete": "Select tracks to delete",
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"}, "@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {"type": "int", "example": "1"}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": {"description": "Extension capability - utility functions"}, "@utilityFunctions": {"description": "Extension capability - utility functions"},
File diff suppressed because it is too large Load Diff
+53 -15
View File
@@ -642,6 +642,20 @@
} }
} }
}, },
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info", "trackMetadataTitle": "Track Info",
"@trackMetadataTitle": { "@trackMetadataTitle": {
"description": "Track metadata screen title" "description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
}, },
"sectionLanguage": "Language", "sectionLanguage": "Language",
"@sectionLanguage": { "@sectionLanguage": {
"description": "Settings section header for language selection" "description": "Settings section header for language"
}, },
"appearanceLanguage": "App Language", "appearanceLanguage": "App Language",
"@appearanceLanguage": { "@appearanceLanguage": {
"description": "Setting title for language selection" "description": "Language setting title"
}, },
"appearanceLanguageSubtitle": "Choose your preferred language", "appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": { "@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting" "description": "Language setting subtitle"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
}, },
"settingsAppearanceSubtitle": "Theme, colors, display", "settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": { "@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
} }
} }
+53 -15
View File
@@ -642,6 +642,20 @@
} }
} }
}, },
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info", "trackMetadataTitle": "Track Info",
"@trackMetadataTitle": { "@trackMetadataTitle": {
"description": "Track metadata screen title" "description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
}, },
"sectionLanguage": "Language", "sectionLanguage": "Language",
"@sectionLanguage": { "@sectionLanguage": {
"description": "Settings section header for language selection" "description": "Settings section header for language"
}, },
"appearanceLanguage": "App Language", "appearanceLanguage": "App Language",
"@appearanceLanguage": { "@appearanceLanguage": {
"description": "Setting title for language selection" "description": "Language setting title"
}, },
"appearanceLanguageSubtitle": "Choose your preferred language", "appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": { "@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting" "description": "Language setting subtitle"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
}, },
"settingsAppearanceSubtitle": "Theme, colors, display", "settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": { "@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
} }
} }
+6
View File
@@ -440,6 +440,11 @@
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz", "qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max", "qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz", "qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
"qualityMp3": "MP3",
"qualityMp3Subtitle": "320kbps (konversi dari FLAC)",
"enableMp3Option": "Aktifkan Opsi MP3",
"enableMp3OptionSubtitleOn": "Opsi kualitas MP3 tersedia",
"enableMp3OptionSubtitleOff": "Unduh FLAC lalu konversi ke MP3 320kbps",
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
"downloadAskBeforeDownload": "Tanya Sebelum Unduh", "downloadAskBeforeDownload": "Tanya Sebelum Unduh",
@@ -660,6 +665,7 @@
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih", "downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", "downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus", "downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder", "folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
"folderOrganizationNone": "Tidak ada", "folderOrganizationNone": "Tidak ada",
+53 -15
View File
@@ -642,6 +642,20 @@
} }
} }
}, },
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info", "trackMetadataTitle": "Track Info",
"@trackMetadataTitle": { "@trackMetadataTitle": {
"description": "Track metadata screen title" "description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
}, },
"sectionLanguage": "Language", "sectionLanguage": "Language",
"@sectionLanguage": { "@sectionLanguage": {
"description": "Settings section header for language selection" "description": "Settings section header for language"
}, },
"appearanceLanguage": "App Language", "appearanceLanguage": "App Language",
"@appearanceLanguage": { "@appearanceLanguage": {
"description": "Setting title for language selection" "description": "Language setting title"
}, },
"appearanceLanguageSubtitle": "Choose your preferred language", "appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": { "@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting" "description": "Language setting subtitle"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
}, },
"settingsAppearanceSubtitle": "Theme, colors, display", "settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": { "@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
} }
} }
+53 -15
View File
@@ -642,6 +642,20 @@
} }
} }
}, },
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info", "trackMetadataTitle": "Track Info",
"@trackMetadataTitle": { "@trackMetadataTitle": {
"description": "Track metadata screen title" "description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
}, },
"sectionLanguage": "Language", "sectionLanguage": "Language",
"@sectionLanguage": { "@sectionLanguage": {
"description": "Settings section header for language selection" "description": "Settings section header for language"
}, },
"appearanceLanguage": "App Language", "appearanceLanguage": "App Language",
"@appearanceLanguage": { "@appearanceLanguage": {
"description": "Setting title for language selection" "description": "Language setting title"
}, },
"appearanceLanguageSubtitle": "Choose your preferred language", "appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": { "@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting" "description": "Language setting subtitle"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
}, },
"settingsAppearanceSubtitle": "Theme, colors, display", "settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": { "@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
} }
} }
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": { "@historyFilterSingles": {
"description": "Filter chip - show singles only" "description": "Filter chip - show singles only"
}, },
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", "historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@historyTracksCount": { "@historyTracksCount": {
"description": "Track count with plural form", "description": "Track count with plural form",
"placeholders": { "placeholders": {
@@ -94,7 +94,7 @@
} }
} }
}, },
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}", "historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}",
"@historyAlbumsCount": { "@historyAlbumsCount": {
"description": "Album count with plural form", "description": "Album count with plural form",
"placeholders": { "placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": { "@albumTitle": {
"description": "Album screen title" "description": "Album screen title"
}, },
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", "albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@albumTracks": { "@albumTracks": {
"description": "Album track count", "description": "Album track count",
"placeholders": { "placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": { "@artistCompilations": {
"description": "Section header for compilations" "description": "Section header for compilations"
}, },
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}", "artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}",
"@artistReleases": { "@artistReleases": {
"description": "Artist release count", "description": "Artist release count",
"placeholders": { "placeholders": {
@@ -1108,7 +1108,7 @@
"@dialogDeleteSelectedTitle": { "@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items" "description": "Dialog title - delete selected items"
}, },
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": { "@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks", "description": "Dialog message - delete selected tracks",
"placeholders": { "placeholders": {
@@ -1169,7 +1169,7 @@
"@snackbarCredentialsCleared": { "@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed" "description": "Snackbar - Spotify credentials removed"
}, },
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@snackbarDeletedTracks": { "@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted", "description": "Snackbar - tracks deleted",
"placeholders": { "placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": { "@selectionTapToSelect": {
"description": "Hint - how to select items" "description": "Hint - how to select items"
}, },
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", "selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionDeleteTracks": { "@selectionDeleteTracks": {
"description": "Delete button with count", "description": "Delete button with count",
"placeholders": { "placeholders": {
@@ -1916,7 +1916,7 @@
} }
} }
}, },
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@tracksCount": { "@tracksCount": {
"description": "Track count display", "description": "Track count display",
"placeholders": { "placeholders": {
@@ -1945,7 +1945,7 @@
"@trackFileInfo": { "@trackFileInfo": {
"description": "Tab title - file information" "description": "Tab title - file information"
}, },
"trackLyrics": "Тексты песен", "trackLyrics": "Текст песни",
"@trackLyrics": { "@trackLyrics": {
"description": "Tab title - lyrics" "description": "Tab title - lyrics"
}, },
@@ -1961,7 +1961,7 @@
"@trackOpenInSpotify": { "@trackOpenInSpotify": {
"description": "Action - open track in Spotify app" "description": "Action - open track in Spotify app"
}, },
"trackTrackName": "Название трека", "trackTrackName": "Название",
"@trackTrackName": { "@trackTrackName": {
"description": "Metadata label - track title" "description": "Metadata label - track title"
}, },
@@ -2520,7 +2520,7 @@
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
}, },
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": { "@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count", "description": "Delete confirmation with count",
"placeholders": { "placeholders": {
@@ -2559,7 +2559,7 @@
"@downloadedAlbumTapToSelect": { "@downloadedAlbumTapToSelect": {
"description": "Selection hint" "description": "Selection hint"
}, },
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@downloadedAlbumDeleteCount": { "@downloadedAlbumDeleteCount": {
"description": "Delete button text with count", "description": "Delete button text with count",
"placeholders": { "placeholders": {
+1 -1
View File
@@ -51,7 +51,7 @@
"@homeSupports": { "@homeSupports": {
"description": "Info text about supported URL types" "description": "Info text about supported URL types"
}, },
"homeRecent": "Recent", "homeRecent": "最新的",
"@homeRecent": { "@homeRecent": {
"description": "Section header for recent searches" "description": "Section header for recent searches"
}, },
+8 -26
View File
@@ -1,48 +1,30 @@
// GENERATED FILE - DO NOT EDIT // GENERATED FILE - DO NOT EDIT
// Generated by: dart run tool/check_translations.dart 0 // Generated by: dart run tool/check_translations.dart 70
// Only languages with >= 0% translation completion are included. // Only languages with >= 70% translation completion are included.
// Translation is measured by comparing VALUES (not just key existence). // Translation is measured by comparing VALUES (not just key existence).
// //
// To regenerate, run: dart run tool/check_translations.dart 0 // To regenerate, run: dart run tool/check_translations.dart 70
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Minimum translation completion threshold used to filter languages. /// Minimum translation completion threshold used to filter languages.
const int translationThreshold = 0; const int translationThreshold = 70;
/// List of locales that meet the translation threshold. /// List of locales that meet the translation threshold.
/// Only these languages will be available in the app. /// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[ const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'), Locale('en'),
Locale('ru'), Locale('ru'),
Locale('es', 'ES'),
Locale('id'), Locale('id'),
Locale('ja'), Locale('pt', 'PT'),
Locale('de'),
Locale('es'),
Locale('fr'),
Locale('hi'),
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
]; ];
/// Set of locale codes for quick lookup. /// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{ const Set<String> filteredLocaleCodes = <String>{
'en', 'en',
'ru', 'ru',
'es_ES',
'id', 'id',
'ja', 'pt_PT',
'de',
'es',
'fr',
'hi',
'ko',
'nl',
'pt',
'zh',
'zh_CN',
'zh_TW',
}; };
+4
View File
@@ -31,6 +31,7 @@ class AppSettings {
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation final bool showExtensionStore; // Show Extension Store tab in navigation
final String locale; // App language: 'system', 'en', 'id', etc. final String locale; // App language: 'system', 'en', 'id', etc.
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion)
const AppSettings({ const AppSettings({
this.defaultService = 'tidal', this.defaultService = 'tidal',
@@ -60,6 +61,7 @@ class AppSettings {
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store this.showExtensionStore = true, // Default: show store
this.locale = 'system', // Default: follow system language this.locale = 'system', // Default: follow system language
this.enableMp3Option = false, // Default: disabled
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -91,6 +93,7 @@ class AppSettings {
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
String? locale, String? locale,
bool? enableMp3Option,
}) { }) {
return AppSettings( return AppSettings(
defaultService: defaultService ?? this.defaultService, defaultService: defaultService ?? this.defaultService,
@@ -120,6 +123,7 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale, locale: locale ?? this.locale,
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
); );
} }
+2
View File
@@ -36,6 +36,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album', json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true, showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system', locale: json['locale'] as String? ?? 'system',
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
); );
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) => Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -67,4 +68,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure, 'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale, 'locale': instance.locale,
'enableMp3Option': instance.enableMp3Option,
}; };
+220 -4
View File
@@ -588,6 +588,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
} catch (e) { } catch (e) {
// Silently ignore polling errors to avoid spamming logs
// Polling is not critical and will retry on next interval
} }
}); });
} }
@@ -1126,13 +1128,139 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (await coverFile.exists()) { if (await coverFile.exists()) {
await coverFile.delete(); await coverFile.delete();
} }
} catch (_) {} } catch (e) {
_log.w('Failed to cleanup cover file: $e');
}
} }
} catch (e) { } catch (e) {
_log.e('Failed to embed metadata: $e'); _log.e('Failed to embed metadata: $e');
} }
} }
/// Embed metadata, lyrics, and cover to a MP3 file
Future<void> _embedMetadataToMp3(String mp3Path, Track track) async {
final settings = ref.read(settingsProvider);
String? coverPath;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality for MP3: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_mp3_$uniqueId.jpg';
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(coverUrl));
final response = await request.close();
if (response.statusCode == 200) {
final file = File(coverPath);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
_log.d('Cover downloaded for MP3: $coverPath');
} else {
_log.w('Failed to download cover for MP3: HTTP ${response.statusCode}');
coverPath = null;
}
httpClient.close();
} catch (e) {
_log.e('Failed to download cover for MP3: $e');
coverPath = null;
}
}
try {
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
metadata['YEAR'] = track.releaseDate!.split('-').first;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
_log.d('MP3 Metadata map content: $metadata');
// Fetch lyrics if embedLyrics is enabled
if (settings.embedLyrics) {
try {
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
filePath: '',
durationMs: durationMs,
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
}
} catch (e) {
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
}
}
_log.d('Embedding tags to MP3: $metadata');
final result = await FFmpegService.embedMetadataToMp3(
mp3Path: mp3Path,
coverPath: coverPath != null && await File(coverPath).exists()
? coverPath
: null,
metadata: metadata,
);
if (result != null) {
_log.d('Metadata, lyrics, and cover embedded to MP3 via FFmpeg');
} else {
_log.w('FFmpeg MP3 metadata/cover embed failed');
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
if (await coverFile.exists()) {
await coverFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup MP3 cover file: $e');
}
}
} catch (e) {
_log.e('Failed to embed metadata to MP3: $e');
}
}
Future<void> _processQueue() async { Future<void> _processQueue() async {
if (state.isProcessing) return; // Prevent multiple concurrent processing if (state.isProcessing) return; // Prevent multiple concurrent processing
@@ -1440,6 +1568,35 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final quality = item.qualityOverride ?? state.audioQuality; final quality = item.qualityOverride ?? state.audioQuality;
// Fetch extended metadata (genre, label) from Deezer if available
String? genre;
String? label;
// Try to get Deezer track ID from various sources
String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
deezerTrackId = trackToDownload.id.split(':')[1];
}
if (deezerTrackId == null && trackToDownload.availability?.deezerId != null) {
deezerTrackId = trackToDownload.availability!.deezerId;
}
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
try {
final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId);
if (extendedMetadata != null) {
genre = extendedMetadata['genre'];
label = extendedMetadata['label'];
if (genre != null && genre.isNotEmpty) {
_log.d('Extended metadata - Genre: $genre, Label: $label');
}
}
} catch (e) {
_log.w('Failed to fetch extended metadata from Deezer: $e');
// Continue without extended metadata
}
}
Map<String, dynamic> result; Map<String, dynamic> result;
final extensionState = ref.read(extensionProvider); final extensionState = ref.read(extensionProvider);
@@ -1469,6 +1626,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
itemId: item.id, itemId: item.id,
durationMs: trackToDownload.duration, durationMs: trackToDownload.duration,
source: trackToDownload.source, // Pass extension ID that provided this track source: trackToDownload.source, // Pass extension ID that provided this track
genre: genre,
label: label,
); );
} else if (state.autoFallback) { } else if (state.autoFallback) {
_log.d('Using auto-fallback mode'); _log.d('Using auto-fallback mode');
@@ -1494,6 +1653,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
itemId: item.id, // Pass item ID for progress tracking itemId: item.id, // Pass item ID for progress tracking
durationMs: durationMs:
trackToDownload.duration, // Duration in ms for verification trackToDownload.duration, // Duration in ms for verification
genre: genre,
label: label,
); );
} else { } else {
result = await PlatformBridge.downloadTrack( result = await PlatformBridge.downloadTrack(
@@ -1543,8 +1704,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (result['success'] == true) { if (result['success'] == true) {
var filePath = result['file_path'] as String?; var filePath = result['file_path'] as String?;
if (filePath != null && filePath.startsWith('EXISTS:')) { // Track if this was an existing file (not a new download)
// This is important to prevent converting existing FLAC files to MP3
final wasExisting = filePath != null && filePath.startsWith('EXISTS:');
if (wasExisting) {
filePath = filePath.substring(7); // Remove "EXISTS:" prefix filePath = filePath.substring(7); // Remove "EXISTS:" prefix
_log.i('Using existing file: $filePath');
} }
_log.i('Download success, file: $filePath'); _log.i('Download success, file: $filePath');
@@ -1677,6 +1842,52 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return; return;
} }
// Convert FLAC to MP3 if MP3 quality was selected
// IMPORTANT: Only convert NEW downloads, never convert existing files
// to prevent overwriting the user's existing FLAC files
if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) {
if (wasExisting) {
// User wanted MP3 but an existing FLAC file was found
// Do NOT convert it - that would delete their existing FLAC
_log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file');
// Keep the existing FLAC file as-is
} else {
_log.i('MP3 quality selected, converting FLAC to MP3...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.97,
);
try {
final mp3Path = await FFmpegService.convertFlacToMp3(
filePath,
bitrate: '320k',
deleteOriginal: true,
);
if (mp3Path != null) {
filePath = mp3Path;
actualQuality = 'MP3 320kbps';
_log.i('Successfully converted to MP3: $mp3Path');
// Embed metadata, lyrics, and cover to the MP3 file
_log.i('Embedding metadata to MP3...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
await _embedMetadataToMp3(mp3Path, trackToDownload);
} else {
_log.w('MP3 conversion failed, keeping FLAC file');
}
} catch (e) {
_log.e('MP3 conversion error: $e, keeping FLAC file');
}
}
}
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.completed, DownloadStatus.completed,
@@ -1716,6 +1927,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? normalizedAlbumArtist ? normalizedAlbumArtist
: null; : null;
// For MP3 files, don't save FLAC bitDepth/sampleRate - they're not applicable
final isMp3 = filePath.endsWith('.mp3');
final historyBitDepth = isMp3 ? null : backendBitDepth;
final historySampleRate = isMp3 ? null : backendSampleRate;
ref ref
.read(downloadHistoryProvider.notifier) .read(downloadHistoryProvider.notifier)
.addToHistory( .addToHistory(
@@ -1750,8 +1966,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? backendYear ? backendYear
: trackToDownload.releaseDate, : trackToDownload.releaseDate,
quality: actualQuality, quality: actualQuality,
bitDepth: backendBitDepth, bitDepth: historyBitDepth,
sampleRate: backendSampleRate, sampleRate: historySampleRate,
), ),
); );
+4 -1
View File
@@ -355,11 +355,12 @@ class QualitySpecificSetting {
class ExtensionSetting { class ExtensionSetting {
final String key; final String key;
final String label; final String label;
final String type; // 'string', 'number', 'boolean', 'select' final String type; // 'string', 'number', 'boolean', 'select', 'button'
final dynamic defaultValue; final dynamic defaultValue;
final String? description; final String? description;
final List<String>? options; // For select type final List<String>? options; // For select type
final bool required; final bool required;
final String? action; // For button type: JS function name to call
const ExtensionSetting({ const ExtensionSetting({
required this.key, required this.key,
@@ -369,6 +370,7 @@ class ExtensionSetting {
this.description, this.description,
this.options, this.options,
this.required = false, this.required = false,
this.action,
}); });
factory ExtensionSetting.fromJson(Map<String, dynamic> json) { factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
@@ -380,6 +382,7 @@ class ExtensionSetting {
description: json['description'] as String?, description: json['description'] as String?,
options: (json['options'] as List<dynamic>?)?.cast<String>(), options: (json['options'] as List<dynamic>?)?.cast<String>(),
required: json['required'] as bool? ?? false, required: json['required'] as bool? ?? false,
action: json['action'] as String?,
); );
} }
} }
+41 -11
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history'; const _recentAccessKey = 'recent_access_history';
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
const _maxRecentItems = 20; const _maxRecentItems = 20;
/// Types of items that can be accessed /// Types of items that can be accessed
@@ -75,19 +76,23 @@ class RecentAccessItem {
/// State for recent access history /// State for recent access history
class RecentAccessState { class RecentAccessState {
final List<RecentAccessItem> items; final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
final bool isLoaded; final bool isLoaded;
const RecentAccessState({ const RecentAccessState({
this.items = const [], this.items = const [],
this.hiddenDownloadIds = const {},
this.isLoaded = false, this.isLoaded = false,
}); });
RecentAccessState copyWith({ RecentAccessState copyWith({
List<RecentAccessItem>? items, List<RecentAccessItem>? items,
Set<String>? hiddenDownloadIds,
bool? isLoaded, bool? isLoaded,
}) { }) {
return RecentAccessState( return RecentAccessState(
items: items ?? this.items, items: items ?? this.items,
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
isLoaded: isLoaded ?? this.isLoaded, isLoaded: isLoaded ?? this.isLoaded,
); );
} }
@@ -104,19 +109,27 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_recentAccessKey); final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
List<RecentAccessItem> items = [];
Set<String> hiddenIds = {};
if (json != null) { if (json != null) {
try { try {
final List<dynamic> decoded = jsonDecode(json); final List<dynamic> decoded = jsonDecode(json);
final items = decoded items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>)) .map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) { } catch (e) {
state = state.copyWith(isLoaded: true); // Ignore parse errors
} }
} else {
state = state.copyWith(isLoaded: true);
} }
if (hiddenJson != null) {
hiddenIds = hiddenJson.toSet();
}
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
} }
Future<void> _saveHistory() async { Future<void> _saveHistory() async {
@@ -125,6 +138,11 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
await prefs.setString(_recentAccessKey, json); await prefs.setString(_recentAccessKey, json);
} }
Future<void> _saveHiddenDownloads() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
}
/// Record an access to an artist /// Record an access to an artist
void recordArtistAccess({ void recordArtistAccess({
required String id, required String id,
@@ -200,9 +218,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
void _recordAccess(RecentAccessItem item) { void _recordAccess(RecentAccessItem item) {
// ignore: avoid_print
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
final updatedItems = state.items final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey) .where((e) => e.uniqueKey != item.uniqueKey)
.toList(); .toList();
@@ -215,9 +230,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
state = state.copyWith(items: updatedItems); state = state.copyWith(items: updatedItems);
_saveHistory(); _saveHistory();
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
} }
/// Remove a specific item from history /// Remove a specific item from history
@@ -229,11 +241,29 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
_saveHistory(); _saveHistory();
} }
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
_saveHiddenDownloads();
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history /// Clear all history
void clearHistory() { void clearHistory() {
state = state.copyWith(items: []); state = state.copyWith(items: []);
_saveHistory(); _saveHistory();
} }
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
_saveHiddenDownloads();
}
} }
/// Provider instance /// Provider instance
+9
View File
@@ -223,6 +223,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(locale: locale); state = state.copyWith(locale: locale);
_saveSettings(); _saveSettings();
} }
void setEnableMp3Option(bool enabled) {
state = state.copyWith(enableMp3Option: enabled);
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS
if (!enabled && state.audioQuality == 'MP3') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
_saveSettings();
}
} }
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>( final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+2
View File
@@ -477,6 +477,8 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack; tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks); state = state.copyWith(tracks: tracks);
} catch (e) { } catch (e) {
// Silently ignore availability check errors
// This is a background operation that shouldn't disrupt the user
} }
} }
+144 -60
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -60,11 +61,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks; List<Track>? _tracks;
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'; final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
ref.read(recentAccessProvider.notifier).recordAlbumAccess( ref.read(recentAccessProvider.notifier).recordAlbumAccess(
@@ -80,6 +86,42 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_tracks == null) { if (_tracks == null) {
_fetchTracks(); _fetchTracks();
} }
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
// Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
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 (_) {
// Ignore palette extraction errors
}
} }
Future<void> _fetchTracks() async { Future<void> _fetchTracks() async {
@@ -143,6 +185,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController,
slivers: [ slivers: [
_buildAppBar(context, colorScheme), _buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme), _buildInfoCard(context, colorScheme),
@@ -167,74 +210,106 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar( return SliverAppBar(
expandedHeight: 280, expandedHeight: 320,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar( title: AnimatedOpacity(
background: Stack( duration: const Duration(milliseconds: 200),
fit: StackFit.expand, opacity: _showTitleInAppBar ? 1.0 : 0.0,
children: [ child: Text(
if (widget.coverUrl != null) widget.albumName,
CachedNetworkImage( style: TextStyle(
imageUrl: widget.coverUrl!, color: colorScheme.onSurface,
fit: BoxFit.cover, fontWeight: FontWeight.w600,
color: Colors.black.withValues(alpha: 0.5), fontSize: 16,
colorBlendMode: BlendMode.darken, ),
memCacheWidth: 600, maxLines: 1,
), overflow: TextOverflow.ellipsis,
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], ),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -244,6 +319,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? []; final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -260,7 +337,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
widget.albumName, widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
), ),
const SizedBox(height: 8), if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty) if (tracks.isNotEmpty)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+41 -3
View File
@@ -96,10 +96,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int? _monthlyListeners; int? _monthlyListeners;
String? _error; String? _error;
@override // Sticky title state
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() { void initState() {
super.initState(); super.initState();
// Setup scroll listener for sticky title
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.extensionId ?? final providerId = widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
@@ -141,9 +148,24 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} }
} else { } else {
_fetchDiscography(); _fetchDiscography();
}
}
void _onScroll() {
// Show title when scrolled past the header (280px trigger)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
} }
} }
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
Future<void> _fetchDiscography() async { Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true); setState(() => _isLoadingDiscography = true);
try { try {
@@ -256,8 +278,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final singles = albums.where((a) => a.albumType == 'single').toList(); final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList(); final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController,
slivers: [ slivers: [
_buildHeader(context, colorScheme), _buildHeader(context, colorScheme),
if (_isLoadingDiscography) if (_isLoadingDiscography)
@@ -307,13 +330,28 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners)); listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
} }
return SliverAppBar( return SliverAppBar(
expandedHeight: 380, expandedHeight: 380,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.artistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
+253 -72
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.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:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
@@ -29,15 +30,72 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> { class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
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) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
/// Get tracks for this album from history provider (reactive) /// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) { List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) { return allItems.where((item) {
final itemKey = '${item.albumName}|${item.albumArtist ?? item.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}'; final albumKey = '${widget.albumName}|${widget.artistName}';
return itemKey == albumKey; return itemKey == albumKey;
}).toList() }).toList()
..sort((a, b) { ..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999; final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999; final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum); if (aNum != bNum) return aNum.compareTo(bNum);
@@ -45,6 +103,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}); });
} }
/// Get unique disc numbers from tracks (sorted)
List<int> _getDiscNumbers(List<DownloadHistoryItem> tracks) {
final discNumbers = tracks
.map((t) => t.discNumber ?? 1)
.toSet()
.toList()
..sort();
return discNumbers;
}
/// Check if album has multiple discs
bool _hasMultipleDiscs(List<DownloadHistoryItem> tracks) {
return _getDiscNumbers(tracks).length > 1;
}
/// Get tracks for a specific disc
List<DownloadHistoryItem> _getTracksForDisc(List<DownloadHistoryItem> tracks, int discNumber) {
return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList();
}
void _enterSelectionMode(String itemId) { void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
setState(() { setState(() {
@@ -161,11 +239,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final tracks = _getAlbumTracks(allHistoryItems); final tracks = _getAlbumTracks(allHistoryItems);
if (tracks.length < 2) { // Show empty state if no tracks found
WidgetsBinding.instance.addPostFrameCallback((_) { if (tracks.isEmpty) {
if (mounted) Navigator.pop(context); return Scaffold(
}); appBar: AppBar(
return const SizedBox.shrink(); title: Text(widget.albumName),
),
body: Center(
child: Text('No tracks found for this album'),
),
);
} }
final validIds = tracks.map((t) => t.id).toSet(); final validIds = tracks.map((t) => t.id).toSet();
@@ -187,6 +270,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
body: Stack( body: Stack(
children: [ children: [
CustomScrollView( CustomScrollView(
controller: _scrollController,
slivers: [ slivers: [
_buildAppBar(context, colorScheme), _buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks),
@@ -211,69 +295,98 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar( return SliverAppBar(
expandedHeight: 280, expandedHeight: 320,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar( title: AnimatedOpacity(
background: Stack( duration: const Duration(milliseconds: 200),
fit: StackFit.expand, opacity: _showTitleInAppBar ? 1.0 : 0.0,
children: [ child: Text(
if (widget.coverUrl != null) widget.albumName,
CachedNetworkImage( style: TextStyle(
imageUrl: widget.coverUrl!, color: colorScheme.onSurface,
fit: BoxFit.cover, fontWeight: FontWeight.w600,
color: Colors.black.withValues(alpha: 0.5), fontSize: 16,
colorBlendMode: BlendMode.darken, ),
memCacheWidth: 600, maxLines: 1,
), overflow: TextOverflow.ellipsis,
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], ),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
@@ -388,16 +501,84 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverList( // Check if album has multiple discs
delegate: SliverChildBuilderDelegate( if (!_hasMultipleDiscs(tracks)) {
(context, index) { // Single disc - use simple list
final track = tracks[index]; return SliverList(
return KeyedSubtree( delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
// Multiple discs - build list with separators
final discNumbers = _getDiscNumbers(tracks);
final List<Widget> children = [];
for (final discNumber in discNumbers) {
final discTracks = _getTracksForDisc(tracks, discNumber);
if (discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) {
children.add(
KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track), child: _buildTrackItem(context, colorScheme, track),
); ),
}, );
childCount: tracks.length, }
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
}
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
],
), ),
); );
} }
+316 -23
View File
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -650,17 +651,64 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) { Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
final historyItems = ref.read(downloadHistoryProvider).items; final historyItems = ref.read(downloadHistoryProvider).items;
final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem( // Group download history by album
id: h.spotifyId!, final albumGroups = <String, List<DownloadHistoryItem>>{};
name: h.trackName, for (final h in historyItems) {
subtitle: h.artistName, final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
imageUrl: h.coverUrl, ? h.albumArtist!
type: RecentAccessType.track, : h.artistName;
accessedAt: h.downloadedAt, final albumKey = '${h.albumName}|$artistForKey';
providerId: 'download', albumGroups.putIfAbsent(albumKey, () => []).add(h);
)).toList(); }
final allItems = [...items, ...downloadItems]; // Convert to RecentAccessItem based on track count:
// - 1 track: show as individual Track
// - 2+ tracks: show as Album
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) {
// Single track - show as Track
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 {
// Multiple tracks - show as Album
downloadItems.add(RecentAccessItem(
id: '${mostRecent.albumName}|$artistForKey',
name: mostRecent.albumName,
subtitle: artistForKey,
imageUrl: mostRecent.coverUrl,
type: RecentAccessType.album,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
));
}
}
// Sort by most recent and take top 10
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
// Filter out hidden downloads (use ref.watch for reactivity)
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)); allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
final seen = <String>{}; final seen = <String>{};
@@ -671,6 +719,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
return true; return true;
}).take(10).toList(); }).take(10).toList();
// Check if there are hidden downloads
final hasHiddenDownloads = hiddenIds.isNotEmpty;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column( child: Column(
@@ -685,19 +736,53 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
TextButton( if (uniqueItems.isNotEmpty)
onPressed: () { TextButton(
ref.read(recentAccessProvider.notifier).clearHistory(); onPressed: () {
}, // Hide ALL download items (not just visible ones)
child: Text( for (final item in downloadItems) {
context.l10n.dialogClearAll, ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
style: TextStyle(color: colorScheme.primary, fontSize: 12), }
// Clear non-download recent history
ref.read(recentAccessProvider.notifier).clearHistory();
},
child: Text(
context.l10n.dialogClearAll,
style: TextStyle(color: colorScheme.primary, fontSize: 12),
),
), ),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), if (uniqueItems.isEmpty && hasHiddenDownloads)
// Show "Show All" button when recents is empty but there are hidden downloads
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
Icon(Icons.visibility_off, size: 48, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
const SizedBox(height: 12),
Text(
'No recent items',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () {
ref.read(recentAccessProvider.notifier).clearHiddenDownloads();
},
icon: const Icon(Icons.visibility, size: 18),
label: const Text('Show All Downloads'),
),
],
),
),
)
else
...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)),
], ],
), ),
); );
@@ -781,7 +866,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
IconButton( IconButton(
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
onPressed: () { onPressed: () {
ref.read(recentAccessProvider.notifier).removeItem(item); if (item.providerId == 'download') {
// For download items, hide from recents without deleting the file
ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
} else {
// For other items, remove from recent history
ref.read(recentAccessProvider.notifier).removeItem(item);
}
}, },
), ),
], ],
@@ -815,7 +906,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
)); ));
} }
case RecentAccessType.album: case RecentAccessType.album:
if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { // Handle downloaded albums - navigate to DownloadedAlbumScreen
if (item.providerId == 'download') {
Navigator.push(context, MaterialPageRoute(
builder: (context) => DownloadedAlbumScreen(
albumName: item.name,
artistName: item.subtitle ?? '',
coverUrl: item.imageUrl,
),
));
} else if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') {
Navigator.push(context, MaterialPageRoute( Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen( builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId!, extensionId: item.providerId!,
@@ -1311,7 +1411,19 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.primary, width: 2), borderSide: BorderSide(color: colorScheme.primary, width: 2),
), ),
prefixIcon: const Icon(Icons.search), prefixIcon: _SearchProviderDropdown(
onProviderChanged: () {
// Reset search state when provider changes
_lastSearchQuery = null;
// Force rebuild to update hint text
setState(() {});
// Re-trigger search if there's text
final text = _urlController.text.trim();
if (text.isNotEmpty && text.length >= _minLiveSearchChars) {
_performSearch(text);
}
},
),
suffixIcon: Row( suffixIcon: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -1364,6 +1476,185 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
/// Dropdown widget for quick search provider switching
class _SearchProviderDropdown extends ConsumerWidget {
final VoidCallback? onProviderChanged;
const _SearchProviderDropdown({this.onProviderChanged});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Get current provider info
final currentProvider = settings.searchProvider;
final searchProviders = extState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
// Find current provider extension
Extension? currentExt;
if (currentProvider != null && currentProvider.isNotEmpty) {
currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull;
}
// Determine display icon
IconData displayIcon = Icons.search;
String? iconPath;
if (currentExt != null) {
iconPath = currentExt.iconPath;
if (currentExt.searchBehavior?.icon != null) {
// Use search behavior icon if available
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
}
}
// Don't show dropdown if no custom search providers available
if (searchProviders.isEmpty) {
return const Icon(Icons.search);
}
return Padding(
padding: const EdgeInsets.only(left: 8),
child: PopupMenuButton<String>(
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (iconPath != null && iconPath.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
File(iconPath),
width: 20,
height: 20,
fit: BoxFit.cover,
errorBuilder: (_, e, st) => Icon(displayIcon, size: 20),
),
)
else
Icon(displayIcon, size: 20),
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down,
size: 16,
color: colorScheme.onSurfaceVariant,
),
],
),
tooltip: 'Change search provider',
offset: const Offset(0, 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (String providerId) {
// Empty string means default (Deezer/Spotify)
final provider = providerId.isEmpty ? null : providerId;
ref.read(settingsProvider.notifier).setSearchProvider(provider);
onProviderChanged?.call();
},
itemBuilder: (context) => [
// Default option (Deezer/Spotify based on metadata source)
PopupMenuItem<String>(
value: '', // Empty string = default provider
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == null || currentProvider.isEmpty
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
settings.metadataSource == 'spotify' ? 'Spotify' : 'Deezer',
style: TextStyle(
fontWeight: currentProvider == null || currentProvider.isEmpty
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == null || currentProvider.isEmpty)
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
// Extension providers
...searchProviders.map((ext) => PopupMenuItem<String>(
value: ext.id,
child: Row(
children: [
if (ext.iconPath != null && ext.iconPath!.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
File(ext.iconPath!),
width: 20,
height: 20,
fit: BoxFit.cover,
errorBuilder: (_, e, st) => Icon(
_getIconFromName(ext.searchBehavior?.icon),
size: 20,
color: currentProvider == ext.id
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
)
else
Icon(
_getIconFromName(ext.searchBehavior?.icon),
size: 20,
color: currentProvider == ext.id
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
ext.displayName,
style: TextStyle(
fontWeight: currentProvider == ext.id
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == ext.id)
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
)),
],
),
);
}
IconData _getIconFromName(String? iconName) {
switch (iconName) {
case 'video':
case 'movie':
return Icons.video_library;
case 'music':
return Icons.music_note;
case 'podcast':
return Icons.podcasts;
case 'book':
case 'audiobook':
return Icons.menu_book;
case 'cloud':
return Icons.cloud;
case 'download':
return Icons.download;
default:
return Icons.search;
}
}
}
/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes /// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes
class _TrackItemWithStatus extends ConsumerWidget { class _TrackItemWithStatus extends ConsumerWidget {
final Track track; final Track track;
@@ -1642,7 +1933,9 @@ class _CollectionItemWidget extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
item.artistName.isNotEmpty ? item.artistName : (isPlaylist ? 'Playlist' : 'Album'), item.artistName.isNotEmpty
? item.artistName
: (isPlaylist ? 'Playlist' : (isArtist ? 'Artist' : 'Album')),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
+168 -62
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -10,7 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Playlist detail screen with Material Expressive 3 design /// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerWidget { class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName; final String playlistName;
final String? coverUrl; final String? coverUrl;
final List<Track> tracks; final List<Track> tracks;
@@ -23,16 +24,66 @@ class PlaylistScreen extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
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 (_) {
// Ignore palette extraction errors
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController,
slivers: [ slivers: [
_buildAppBar(context, colorScheme), _buildAppBar(context, colorScheme),
_buildInfoCard(context, ref, colorScheme), _buildInfoCard(context, colorScheme),
_buildTrackListHeader(context, colorScheme), _buildTrackListHeader(context, colorScheme),
_buildTrackList(context, ref, colorScheme), _buildTrackList(context, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)), const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
@@ -40,59 +91,114 @@ class PlaylistScreen extends ConsumerWidget {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar( return SliverAppBar(
expandedHeight: 280, expandedHeight: 320,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar( title: AnimatedOpacity(
background: Stack( duration: const Duration(milliseconds: 200),
fit: StackFit.expand, opacity: _showTitleInAppBar ? 1.0 : 0.0,
children: [ child: Text(
if (coverUrl != null) widget.playlistName,
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600), style: TextStyle(
Container( color: colorScheme.onSurface,
decoration: BoxDecoration( fontWeight: FontWeight.w600,
gradient: LinearGradient( fontSize: 16,
begin: Alignment.topCenter, ),
end: Alignment.bottomCenter, maxLines: 1,
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface], overflow: TextOverflow.ellipsis,
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: coverUrl != null
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], ),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
), ),
leading: IconButton( leading: IconButton(
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)), icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
); );
} }
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -105,7 +211,7 @@ class PlaylistScreen extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -115,15 +221,15 @@ class PlaylistScreen extends ConsumerWidget {
children: [ children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer), Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: () => _downloadAll(context, ref), onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
label: Text(context.l10n.downloadAllCount(tracks.length)), 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(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
), ),
], ],
@@ -149,25 +255,25 @@ class PlaylistScreen extends ConsumerWidget {
); );
} }
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final track = tracks[index]; final track = widget.tracks[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _PlaylistTrackItem( child: _PlaylistTrackItem(
track: track, track: track,
onDownload: () => _downloadTrack(context, ref, track), onDownload: () => _downloadTrack(context, track),
), ),
); );
}, },
childCount: tracks.length, childCount: widget.tracks.length,
), ),
); );
} }
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) { void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) { if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show( DownloadServicePicker.show(
@@ -186,22 +292,22 @@ class PlaylistScreen extends ConsumerWidget {
} }
} }
void _downloadAll(BuildContext context, WidgetRef ref) { void _downloadAll(BuildContext context) {
if (tracks.isEmpty) return; if (widget.tracks.isEmpty) return;
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) { if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show( DownloadServicePicker.show(
context, context,
trackName: '${tracks.length} tracks', trackName: '${widget.tracks.length} tracks',
artistName: playlistName, artistName: widget.playlistName,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.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(widget.tracks.length))));
} }
} }
} }
+141 -1
View File
@@ -86,6 +86,13 @@ class AboutPage extends StatelessWidget {
), ),
), ),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
),
const SliverToBoxAdapter(
child: _TranslatorsSection(),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks), child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
), ),
@@ -395,7 +402,140 @@ class _ContributorItem extends StatelessWidget {
} }
} }
/// Settings item with 40x40 icon area to align with contributor avatars /// Translator data model
class _Translator {
final String name;
final String crowdinUsername;
final String language;
final String flag;
const _Translator({
required this.name,
required this.crowdinUsername,
required this.language,
required this.flag,
});
}
/// Translators section with compact chip-style layout
class _TranslatorsSection extends StatelessWidget {
const _TranslatorsSection();
static const List<_Translator> _translators = [
_Translator(
name: 'Pedro Marcondes',
crowdinUsername: 'justapedro',
language: 'Portuguese',
flag: '🇵🇹',
),
_Translator(
name: 'Credits 125',
crowdinUsername: 'credits125',
language: 'Spanish',
flag: '🇪🇸',
),
_Translator(
name: 'Владислав',
crowdinUsername: 'odinokiy_kot',
language: 'Russian',
flag: '🇷🇺',
),
_Translator(
name: 'Max',
crowdinUsername: 'amonoman',
language: 'German',
flag: '🇩🇪',
),
];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip(
translator: translator,
)).toList(),
),
),
);
}
}
/// Individual translator chip
class _TranslatorChip extends StatelessWidget {
final _Translator translator;
const _TranslatorChip({required this.translator});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () => _launchCrowdin(translator.crowdinUsername),
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
const SizedBox(width: 8),
Text(
translator.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 6),
Text(
translator.flag,
style: const TextStyle(fontSize: 14),
),
],
),
),
),
);
}
Future<void> _launchCrowdin(String username) async {
final uri = Uri.parse('https://crowdin.com/profile/$username');
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
class _AboutSettingsItem extends StatelessWidget { class _AboutSettingsItem extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
@@ -694,20 +694,23 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged, required this.onChanged,
}); });
static const _allLanguages = [ static const _allLanguages = [
('system', 'System Default', Icons.phone_android), ('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language), ('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language), ('id', 'Bahasa Indonesia', Icons.language),
('de', 'Deutsch', Icons.language), ('de', 'Deutsch', Icons.language),
('es', 'Español', Icons.language), ('es', 'Español', Icons.language),
('es_ES', 'Español (España)', Icons.language),
('fr', 'Français', Icons.language), ('fr', 'Français', Icons.language),
('hi', 'हिन्दी', Icons.language), ('hi', 'हिन्दी', Icons.language),
('ja', '日本語', Icons.language), ('ja', '日本語', Icons.language),
('ko', '한국어', Icons.language), ('ko', '한국어', Icons.language),
('nl', 'Nederlands', Icons.language), ('nl', 'Nederlands', Icons.language),
('pt', 'Português', Icons.language), ('pt', 'Português', Icons.language),
('pt_PT', 'Português (Portugal)', Icons.language),
('ru', 'Русский', Icons.language), ('ru', 'Русский', Icons.language),
('zh', '简体中文', Icons.language), ('zh', '简体中文', Icons.language),
('zh_CN', '简体中文 (中国)', Icons.language),
('zh_TW', '繁體中文', Icons.language), ('zh_TW', '繁體中文', Icons.language),
]; ];
@@ -99,6 +99,17 @@ class DownloadSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value), .setAskQualityBeforeDownload(value),
), ),
SettingsSwitchItem(
icon: Icons.audiotrack,
title: context.l10n.enableMp3Option,
subtitle: settings.enableMp3Option
? context.l10n.enableMp3OptionSubtitleOn
: context.l10n.enableMp3OptionSubtitleOff,
value: settings.enableMp3Option,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEnableMp3Option(value),
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption( _QualityOption(
title: context.l10n.qualityFlacLossless, title: context.l10n.qualityFlacLossless,
@@ -123,8 +134,18 @@ class DownloadSettingsPage extends ConsumerWidget {
onTap: () => ref onTap: () => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'), .setAudioQuality('HI_RES_LOSSLESS'),
showDivider: false, showDivider: settings.enableMp3Option,
), ),
if (settings.enableMp3Option)
_QualityOption(
title: context.l10n.qualityMp3,
subtitle: context.l10n.qualityMp3Subtitle,
isSelected: settings.audioQuality == 'MP3',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('MP3'),
showDivider: false,
),
], ],
if (!isBuiltInService) ...[ if (!isBuiltInService) ...[
Padding( Padding(
+129 -20
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget { class ExtensionDetailPage extends ConsumerStatefulWidget {
@@ -342,6 +343,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
value: _settings[setting.key] ?? setting.defaultValue, value: _settings[setting.key] ?? setting.defaultValue,
showDivider: index < extension.settings.length - 1, showDivider: index < extension.settings.length - 1,
onChanged: (value) => _updateSetting(setting.key, value), onChanged: (value) => _updateSetting(setting.key, value),
extensionId: widget.extensionId,
); );
}).toList(), }).toList(),
), ),
@@ -587,41 +589,62 @@ class _PermissionItem extends StatelessWidget {
} }
} }
class _SettingItem extends StatelessWidget { class _SettingItem extends StatefulWidget {
final ExtensionSetting setting; final ExtensionSetting setting;
final dynamic value; final dynamic value;
final bool showDivider; final bool showDivider;
final ValueChanged<dynamic> onChanged; final ValueChanged<dynamic> onChanged;
final String extensionId;
const _SettingItem({ const _SettingItem({
required this.setting, required this.setting,
required this.value, required this.value,
required this.onChanged, required this.onChanged,
required this.extensionId,
this.showDivider = true, this.showDivider = true,
}); });
@override
State<_SettingItem> createState() => _SettingItemState();
}
class _SettingItemState extends State<_SettingItem> {
bool _isLoading = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
Widget trailing; Widget trailing;
switch (setting.type) { switch (widget.setting.type) {
case 'boolean': case 'boolean':
trailing = Switch( trailing = Switch(
value: value as bool? ?? false, value: widget.value as bool? ?? false,
onChanged: onChanged, onChanged: widget.onChanged,
); );
break; break;
case 'select': case 'select':
trailing = DropdownButton<String>( trailing = DropdownButton<String>(
value: value as String?, value: widget.value as String?,
items: setting.options?.map((opt) { items: widget.setting.options?.map((opt) {
return DropdownMenuItem(value: opt, child: Text(opt)); return DropdownMenuItem(value: opt, child: Text(opt));
}).toList(), }).toList(),
onChanged: onChanged, onChanged: widget.onChanged,
underline: const SizedBox(), underline: const SizedBox(),
); );
break; break;
case 'button':
trailing = _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: FilledButton.tonal(
onPressed: () => _invokeAction(context),
child: Text(widget.setting.label),
);
break;
default: default:
trailing = Icon( trailing = Icon(
Icons.chevron_right, Icons.chevron_right,
@@ -629,11 +652,52 @@ class _SettingItem extends StatelessWidget {
); );
} }
// For button type, show a different layout
if (widget.setting.type == 'button') {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.setting.description != null) ...[
Text(
widget.setting.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
],
],
),
),
trailing,
],
),
),
if (widget.showDivider)
Divider(
height: 1,
thickness: 1,
indent: 16,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
InkWell( InkWell(
onTap: setting.type == 'string' || setting.type == 'number' onTap: widget.setting.type == 'string' || widget.setting.type == 'number'
? () => _showEditDialog(context) ? () => _showEditDialog(context)
: null, : null,
child: Padding( child: Padding(
@@ -645,22 +709,22 @@ class _SettingItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
setting.label, widget.setting.label,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
if (setting.description != null) ...[ if (widget.setting.description != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
setting.description!, widget.setting.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
if (setting.type == 'string' || setting.type == 'number') ...[ if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value?.toString() ?? 'Not set', widget.value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
), ),
@@ -674,7 +738,7 @@ class _SettingItem extends StatelessWidget {
), ),
), ),
), ),
if (showDivider) if (widget.showDivider)
Divider( Divider(
height: 1, height: 1,
thickness: 1, thickness: 1,
@@ -686,21 +750,66 @@ class _SettingItem extends StatelessWidget {
); );
} }
Future<void> _invokeAction(BuildContext context) async {
if (widget.setting.action == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No action defined for this button')),
);
return;
}
setState(() => _isLoading = true);
try {
final result = await PlatformBridge.invokeExtensionAction(
widget.extensionId,
widget.setting.action!,
);
if (context.mounted) {
final success = result['success'] as bool? ?? false;
if (!success) {
final error = result['error'] as String? ?? 'Action failed';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
} else {
final message = result['message'] as String?;
if (message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _showEditDialog(BuildContext context) { void _showEditDialog(BuildContext context) {
final controller = TextEditingController(text: value?.toString() ?? ''); final controller = TextEditingController(text: widget.value?.toString() ?? '');
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(setting.label), title: Text(widget.setting.label),
content: TextField( content: TextField(
controller: controller, controller: controller,
keyboardType: setting.type == 'number' keyboardType: widget.setting.type == 'number'
? TextInputType.number ? TextInputType.number
: TextInputType.text, : TextInputType.text,
decoration: InputDecoration( decoration: InputDecoration(
hintText: setting.description ?? 'Enter value', hintText: widget.setting.description ?? 'Enter value',
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -716,10 +825,10 @@ class _SettingItem extends StatelessWidget {
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final newValue = setting.type == 'number' final newValue = widget.setting.type == 'number'
? num.tryParse(controller.text) ? num.tryParse(controller.text)
: controller.text; : controller.text;
onChanged(newValue); widget.onChanged(newValue);
Navigator.pop(context); Navigator.pop(context);
}, },
child: Text(context.l10n.dialogSave), child: Text(context.l10n.dialogSave),
+147 -58
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.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:open_filex/open_filex.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -28,6 +29,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? _lyrics; String? _lyrics;
bool _lyricsLoading = false; bool _lyricsLoading = false;
String? _lyricsError; String? _lyricsError;
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
String? _normalizeOptionalString(String? value) { String? _normalizeOptionalString(String? value) {
if (value == null) return null; if (value == null) return null;
@@ -40,7 +44,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll);
_checkFile(); _checkFile();
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.item.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.item.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
} }
Future<void> _checkFile() async { Future<void> _checkFile() async {
@@ -91,21 +130,48 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController,
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 280, expandedHeight: 320,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface, // Use theme color for collapsed state
flexibleSpace: FlexibleSpaceBar( surfaceTintColor: Colors.transparent,
background: _buildHeaderBackground(context, colorScheme), title: AnimatedOpacity(
stretchModes: const [ duration: const Duration(milliseconds: 200),
StretchMode.zoomBackground, opacity: _showTitleInAppBar ? 1.0 : 0.0,
StretchMode.blurBackground, child: Text(
], trackName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
@@ -167,74 +233,74 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
); );
} }
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) { Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, Color bgColor, bool showContent) {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (item.coverUrl != null) // Background with dominant color
CachedNetworkImage( AnimatedContainer(
imageUrl: item.coverUrl!, duration: const Duration(milliseconds: 500),
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
),
Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Colors.transparent, bgColor,
colorScheme.surface.withValues(alpha: 0.8), bgColor.withValues(alpha: 0.8),
colorScheme.surface, colorScheme.surface,
], ],
stops: const [0.0, 0.7, 1.0], stops: const [0.0, 0.6, 1.0],
), ),
), ),
), ),
Center( // Cover image centered - fade out when collapsing
child: Padding( AnimatedOpacity(
padding: const EdgeInsets.only(top: 60), duration: const Duration(milliseconds: 150),
child: Hero( opacity: showContent ? 1.0 : 0.0,
tag: 'cover_${item.id}', child: Center(
child: Container( child: Padding(
width: 140, padding: const EdgeInsets.only(top: 60),
height: 140, child: Hero(
decoration: BoxDecoration( tag: 'cover_${item.id}',
borderRadius: BorderRadius.circular(16), child: Container(
boxShadow: [ width: coverSize,
BoxShadow( height: coverSize,
color: Colors.black.withValues(alpha: 0.3), decoration: BoxDecoration(
blurRadius: 20, borderRadius: BorderRadius.circular(20),
offset: const Offset(0, 10), boxShadow: [
), BoxShadow(
], color: Colors.black.withValues(alpha: 0.4),
), blurRadius: 30,
child: ClipRRect( offset: const Offset(0, 15),
borderRadius: BorderRadius.circular(16), ),
child: item.coverUrl != null ],
? CachedNetworkImage( ),
imageUrl: item.coverUrl!, child: ClipRRect(
fit: BoxFit.cover, borderRadius: BorderRadius.circular(20),
placeholder: (_, _) => Container( child: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
placeholder: (_, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.music_note, Icons.music_note,
size: 48, size: 64,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
) ),
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
), ),
), ),
), ),
@@ -425,8 +491,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string based on file type
String? audioQualityStr; String? audioQualityStr;
if (bitDepth != null && sampleRate != null) { final fileName = item.filePath.split('/').last;
final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : '';
if (fileExt == 'MP3') {
audioQualityStr = '320kbps';
} else if (bitDepth != null && sampleRate != null) {
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} }
@@ -578,7 +650,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
), ),
if (bitDepth != null && sampleRate != null) // Show 320kbps for MP3, bit depth/sample rate for FLAC
if (fileExtension == 'MP3')
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'320kbps',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
else if (bitDepth != null && sampleRate != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
+147 -9
View File
@@ -48,18 +48,14 @@ class FFmpegService {
} }
/// Convert FLAC to MP3 /// Convert FLAC to MP3
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3( static Future<String?> convertFlacToMp3(
String inputPath, { String inputPath, {
String bitrate = '320k', String bitrate = '320k',
bool deleteOriginal = true,
}) async { }) async {
final dir = File(inputPath).parent.path; // Convert in same folder, just change extension
final baseName = final outputPath = inputPath.replaceAll('.flac', '.mp3');
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
final command = final command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
@@ -67,6 +63,12 @@ class FFmpegService {
final result = await _execute(command); final result = await _execute(command);
if (result.success) { if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath; return outputPath;
} }
@@ -201,11 +203,147 @@ class FFmpegService {
if (await tempFile.exists()) { if (await tempFile.exists()) {
await tempFile.delete(); await tempFile.delete();
} }
} catch (_) {} } catch (e) {
_log.w('Failed to cleanup temp file: $e');
}
_log.e('Metadata/Cover embed failed: ${result.output}'); _log.e('Metadata/Cover embed failed: ${result.output}');
return null; return null;
} }
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(mp3Path);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(mp3Path);
await tempFile.delete();
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} else {
_log.e('Temp MP3 output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup temp MP3 file: $e');
}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
} }
/// Result of FFmpeg command execution /// Result of FFmpeg command execution
+45
View File
@@ -129,6 +129,10 @@ class PlatformBridge {
String preferredService = 'tidal', String preferredService = 'tidal',
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
// Extended metadata for FLAC tagging
String? genre,
String? label,
String? copyright,
}) async { }) async {
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
final request = jsonEncode({ final request = jsonEncode({
@@ -151,6 +155,10 @@ class PlatformBridge {
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs, 'duration_ms': durationMs,
// Extended metadata
'genre': genre ?? '',
'label': label ?? '',
'copyright': copyright ?? '',
}); });
final result = await _channel.invokeMethod('downloadWithFallback', request); final result = await _channel.invokeMethod('downloadWithFallback', request);
@@ -411,6 +419,25 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get extended metadata (genre, label) from Deezer using track ID
/// Returns {"genre": "...", "label": "..."} or null if not found
static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async {
try {
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
'track_id': trackId,
});
if (result == null) return null;
final data = jsonDecode(result as String) as Map<String, dynamic>;
return {
'genre': data['genre'] as String? ?? '',
'label': data['label'] as String? ?? '',
};
} catch (e) {
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
return null;
}
}
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback) /// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async { static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', { final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
@@ -583,6 +610,20 @@ class PlatformBridge {
}); });
} }
/// Invoke an action on an extension (e.g., button click handler like "startLogin")
/// Returns the result from the JS function
static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async {
_log.d('invokeExtensionAction: $extensionId.$actionName');
final result = await _channel.invokeMethod('invokeExtensionAction', {
'extension_id': extensionId,
'action': actionName,
});
if (result == null || (result as String).isEmpty) {
return {'success': true};
}
return jsonDecode(result) as Map<String, dynamic>;
}
/// Search tracks using extension providers /// Search tracks using extension providers
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async { static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
_log.d('searchTracksWithExtensions: "$query"'); _log.d('searchTracksWithExtensions: "$query"');
@@ -615,6 +656,8 @@ class PlatformBridge {
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
String? source, // Extension ID that provided this track (prioritize this extension) String? source, // Extension ID that provided this track (prioritize this extension)
String? genre,
String? label,
}) async { }) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
final request = jsonEncode({ final request = jsonEncode({
@@ -637,6 +680,8 @@ class PlatformBridge {
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs, 'duration_ms': durationMs,
'source': source ?? '', // Extension ID that provided this track 'source': source ?? '', // Extension ID that provided this track
'genre': genre ?? '',
'label': label ?? '',
}); });
final result = await _channel.invokeMethod('downloadWithExtensions', request); final result = await _channel.invokeMethod('downloadWithExtensions', request);
+23 -2
View File
@@ -49,6 +49,13 @@ const _builtInServices = [
), ),
]; ];
/// MP3 quality option (shown when enabled in settings)
const _mp3QualityOption = QualityOption(
id: 'MP3',
label: 'MP3',
description: '320kbps (converted from FLAC)',
);
/// A reusable widget for selecting download service (built-in + extensions) /// A reusable widget for selecting download service (built-in + extensions)
class DownloadServicePicker extends ConsumerStatefulWidget { class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName; final String? trackName;
@@ -105,20 +112,34 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
/// Get quality options for the selected service /// Get quality options for the selected service
List<QualityOption> _getQualityOptions() { List<QualityOption> _getQualityOptions() {
final settings = ref.read(settingsProvider);
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
if (builtIn != null) { if (builtIn != null) {
// Add MP3 option if enabled in settings
if (settings.enableMp3Option) {
return [...builtIn.qualityOptions, _mp3QualityOption];
}
return builtIn.qualityOptions; return builtIn.qualityOptions;
} }
final extensionState = ref.read(extensionProvider); final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) { if (ext != null && ext.qualityOptions.isNotEmpty) {
// Add MP3 option for extensions too if enabled
if (settings.enableMp3Option) {
return [...ext.qualityOptions, _mp3QualityOption];
}
return ext.qualityOptions; return ext.qualityOptions;
} }
return const [ // Default fallback options
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), final defaultOptions = [
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
]; ];
if (settings.enableMp3Option) {
return [...defaultOptions, _mp3QualityOption];
}
return defaultOptions;
} }
@override @override
+8
View File
@@ -653,6 +653,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
url: "https://pub.dev"
source: hosted
version: "0.3.3+7"
path: path:
dependency: transitive dependency: transitive
description: description:
+2 -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.1.1+60 version: 3.1.2+61
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -38,6 +38,7 @@ dependencies:
# Material Expressive 3 / Dynamic Color # Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1 material_color_utilities: ^0.11.1
palette_generator: ^0.3.3+4
# Permissions # Permissions
permission_handler: ^12.0.1 permission_handler: ^12.0.1
+2 -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.1.1+60 version: 3.1.2+61
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -38,6 +38,7 @@ dependencies:
# Material Expressive 3 / Dynamic Color # Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1 material_color_utilities: ^0.11.1
palette_generator: ^0.3.3+4
# Permissions # Permissions
permission_handler: ^12.0.1 permission_handler: ^12.0.1