mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61720f3f2a | |||
| 7749399239 | |||
| d143b82068 | |||
| 606e7c1079 | |||
| a650632c4e | |||
| 3c118f74e4 | |||
| bc3055f6e1 | |||
| 7c86ae0b7e | |||
| 595bfb2711 | |||
| 5f39a3d52f | |||
| e7077781e6 | |||
| 42d15db4ca | |||
| c2599981d6 | |||
| a1647a41ff | |||
| bf2fc7702b | |||
| f814408702 | |||
| 6b1958bfd0 | |||
| bc120ffa76 | |||
| 5ea454a0b0 | |||
| da574f895c | |||
| 1c445e91d9 | |||
| 5d03eb0656 | |||
| becb6845a6 | |||
| be3ee3b216 | |||
| 3747674968 | |||
| ff9d088c5f | |||
| 12db11d559 | |||
| 7e1aca33a5 | |||
| 07a1c68354 | |||
| f4d7c6531f | |||
| e9ca054682 | |||
| 1069bdd0d8 | |||
| ff882a58d7 | |||
| dddc8c3d94 | |||
| 720525b67b | |||
| cc12f63d36 | |||
| 5c67553596 |
+109
@@ -1,5 +1,114 @@
|
||||
# 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -284,6 +284,13 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerExtendedMetadata" -> {
|
||||
val trackId = call.argument<String>("track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDeezerExtendedMetadata(trackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"convertSpotifyToDeezer" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
@@ -438,6 +445,14 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
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" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 20
|
||||
|
||||
@@ -42,17 +42,27 @@ class FFmpegServiceIOS {
|
||||
}
|
||||
|
||||
/// Convert FLAC to MP3
|
||||
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName = 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';
|
||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) 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 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}');
|
||||
return null;
|
||||
}
|
||||
@@ -177,6 +187,123 @@ class FFmpegServiceIOS {
|
||||
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
|
||||
static Future<bool> isAvailable() async {
|
||||
try {
|
||||
|
||||
@@ -564,6 +564,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNum,
|
||||
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
|
||||
|
||||
+107
-9
@@ -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 {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
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"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
Tracks struct {
|
||||
@@ -310,12 +319,23 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
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{
|
||||
TotalTracks: album.NbTracks,
|
||||
Name: album.Title,
|
||||
ReleaseDate: album.ReleaseDate,
|
||||
Artists: artistName,
|
||||
Images: albumImage,
|
||||
Genre: genreStr, // From Deezer album
|
||||
Label: album.Label, // From Deezer album
|
||||
}
|
||||
|
||||
// Fetch ISRCs in parallel
|
||||
@@ -677,6 +697,84 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
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 {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -153,6 +153,10 @@ type DownloadRequest struct {
|
||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||
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
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
@@ -837,6 +841,37 @@ func ParseDeezerURLExport(url string) (string, error) {
|
||||
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
|
||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -1290,6 +1325,23 @@ func CleanupExtensions() {
|
||||
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 ====================
|
||||
|
||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
||||
|
||||
@@ -959,3 +959,60 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
SettingTypeNumber SettingType = "number"
|
||||
SettingTypeBool SettingType = "boolean"
|
||||
SettingTypeSelect SettingType = "select"
|
||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||
)
|
||||
|
||||
// ExtensionPermissions defines what resources an extension can access
|
||||
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
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
|
||||
@@ -204,6 +206,7 @@ func (m *ExtensionManifest) Validate() error {
|
||||
SettingTypeNumber: true,
|
||||
SettingTypeBool: true,
|
||||
SettingTypeSelect: true,
|
||||
SettingTypeButton: true,
|
||||
}
|
||||
if !validTypes[setting.Type] {
|
||||
return &ManifestValidationError{
|
||||
@@ -219,6 +222,14 @@ func (m *ExtensionManifest) Validate() error {
|
||||
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
|
||||
|
||||
@@ -797,6 +797,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
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 ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
@@ -937,6 +946,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
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 ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
|
||||
@@ -24,6 +24,9 @@ type Metadata struct {
|
||||
ISRC string
|
||||
Description 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
|
||||
@@ -82,6 +85,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
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()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -180,6 +195,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
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()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -348,6 +375,53 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
||||
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
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
|
||||
@@ -1120,6 +1120,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||
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
|
||||
|
||||
@@ -182,6 +182,9 @@ type AlbumInfoMetadata struct {
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artists string `json:"artists"`
|
||||
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
|
||||
|
||||
@@ -1716,6 +1716,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: track.VolumeNumber, // Use actual disc number 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
|
||||
|
||||
@@ -227,6 +227,13 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
@@ -375,6 +382,14 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.1.1';
|
||||
static const String buildNumber = '60';
|
||||
static const String version = '3.1.2';
|
||||
static const String buildNumber = '61';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ abstract class AppLocalizations {
|
||||
Locale('de'),
|
||||
Locale('en'),
|
||||
Locale('es'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('fr'),
|
||||
Locale('hi'),
|
||||
Locale('id'),
|
||||
@@ -114,6 +115,7 @@ abstract class AppLocalizations {
|
||||
Locale('ko'),
|
||||
Locale('nl'),
|
||||
Locale('pt'),
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ru'),
|
||||
Locale('zh'),
|
||||
Locale('zh', 'CN'),
|
||||
@@ -816,6 +818,12 @@ abstract class AppLocalizations {
|
||||
/// **'The talented artist who created our beautiful app logo!'**
|
||||
String get aboutLogoArtist;
|
||||
|
||||
/// Section for translators
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translators'**
|
||||
String get aboutTranslators;
|
||||
|
||||
/// Section for special thanks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3252,6 +3260,36 @@ abstract class AppLocalizations {
|
||||
/// **'24-bit / up to 192kHz'**
|
||||
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
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3588,6 +3626,12 @@ abstract class AppLocalizations {
|
||||
/// **'Select tracks to delete'**
|
||||
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
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3663,6 +3707,22 @@ class _AppLocalizationsDelegate
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
// Lookup logic when language+country codes are specified.
|
||||
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':
|
||||
{
|
||||
switch (locale.countryCode) {
|
||||
|
||||
@@ -115,7 +115,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsTitle => 'Einstellungen';
|
||||
|
||||
@override
|
||||
String get settingsDownload => 'Download';
|
||||
String get settingsDownload => 'Herunterladen';
|
||||
|
||||
@override
|
||||
String get settingsAppearance => 'Erscheinungsbild';
|
||||
@@ -130,7 +130,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsAbout => 'Über';
|
||||
|
||||
@override
|
||||
String get downloadTitle => 'Download';
|
||||
String get downloadTitle => 'Herunterladen';
|
||||
|
||||
@override
|
||||
String get downloadLocation => 'Download-Speicherort';
|
||||
@@ -410,40 +410,46 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Besonderer Dank';
|
||||
|
||||
@override
|
||||
String get aboutLinks => 'Links';
|
||||
|
||||
@override
|
||||
String get aboutMobileSource => 'Mobile source code';
|
||||
String get aboutMobileSource => 'Mobiler Quellcode';
|
||||
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
String get aboutPCSource => 'PC Quellcode';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
String get aboutReportIssue => 'Problem melden';
|
||||
|
||||
@override
|
||||
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
|
||||
String get aboutReportIssueSubtitle =>
|
||||
'Melde jedes Problem, die dir auftreten';
|
||||
|
||||
@override
|
||||
String get aboutFeatureRequest => 'Feature request';
|
||||
String get aboutFeatureRequest => 'Feature vorschlagen';
|
||||
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
String get aboutFeatureRequestSubtitle =>
|
||||
'Schlage neue Funktionen für die App vor';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
String get aboutBuyMeCoffeeSubtitle =>
|
||||
'Unterstütze die Entwicklung auf Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
@@ -453,25 +459,25 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
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
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@override
|
||||
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
|
||||
String get aboutDabMusic => 'DAB Music';
|
||||
|
||||
@override
|
||||
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
|
||||
String get aboutAppDescription =>
|
||||
@@ -1792,6 +1798,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1983,6 +2005,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
|
||||
@@ -402,6 +402,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -1782,6 +1785,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1973,6 +1992,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -402,6 +402,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -1782,6 +1785,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1973,6 +1992,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
|
||||
@@ -402,6 +402,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -1782,6 +1785,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1973,6 +1992,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
|
||||
@@ -406,6 +406,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Terima Kasih Khusus';
|
||||
|
||||
@@ -1794,6 +1797,22 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||
@@ -1986,6 +2005,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Fungsi Utilitas';
|
||||
|
||||
|
||||
@@ -402,6 +402,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'スペシャルサンクス';
|
||||
|
||||
@@ -1782,6 +1785,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1973,6 +1992,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
|
||||
@@ -402,6 +402,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -1782,6 +1785,22 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1973,6 +1992,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
|
||||
@@ -402,6 +402,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -1782,6 +1785,22 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1973,6 +1992,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,9 +74,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -87,9 +87,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count альбомов',
|
||||
one: '1 альбом',
|
||||
many: '$count альбомов',
|
||||
few: '$count альбома',
|
||||
one: '$count альбом',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -414,6 +414,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'Талантливый художник, который создал наш красивый логотип приложения!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Особая благодарность';
|
||||
|
||||
@@ -489,9 +492,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -523,9 +526,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count релизов',
|
||||
one: '1 релиз',
|
||||
many: '$count релизов',
|
||||
few: '$count релиза',
|
||||
one: '$count релиз',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -901,9 +904,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -946,9 +949,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалено $count $_temp0';
|
||||
}
|
||||
@@ -1095,9 +1098,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -1510,9 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -1533,7 +1536,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackFileInfo => 'Информация о файле';
|
||||
|
||||
@override
|
||||
String get trackLyrics => 'Тексты песен';
|
||||
String get trackLyrics => 'Текст песни';
|
||||
|
||||
@override
|
||||
String get trackFileNotFound => 'Файл не найден';
|
||||
@@ -1545,7 +1548,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackOpenInSpotify => 'Открыть в Spotify';
|
||||
|
||||
@override
|
||||
String get trackTrackName => 'Название трека';
|
||||
String get trackTrackName => 'Название';
|
||||
|
||||
@override
|
||||
String get trackArtist => 'Исполнитель';
|
||||
@@ -1820,6 +1823,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Фактическое качество зависит от доступности треков в сервисе';
|
||||
@@ -1976,9 +1995,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -2008,9 +2027,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -2018,6 +2037,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Функции утилиты';
|
||||
|
||||
|
||||
@@ -402,6 +402,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -1782,6 +1785,22 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1973,6 +1992,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -4035,7 +4059,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
|
||||
@override
|
||||
String get homeRecent => 'Recent';
|
||||
String get homeRecent => '最新的';
|
||||
|
||||
@override
|
||||
String get historyTitle => 'History';
|
||||
|
||||
+70
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"@@locale": "de",
|
||||
"@@last_modified": "2026-01-17",
|
||||
"@@last_modified": "2026-01-16",
|
||||
"appName": "SpotiFLAC",
|
||||
"@appName": {
|
||||
"description": "App name - DO NOT TRANSLATE"
|
||||
@@ -131,7 +131,7 @@
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
},
|
||||
"settingsDownload": "Download",
|
||||
"settingsDownload": "Herunterladen",
|
||||
"@settingsDownload": {
|
||||
"description": "Settings section - download options"
|
||||
},
|
||||
@@ -151,7 +151,7 @@
|
||||
"@settingsAbout": {
|
||||
"description": "Settings section - app info"
|
||||
},
|
||||
"downloadTitle": "Download",
|
||||
"downloadTitle": "Herunterladen",
|
||||
"@downloadTitle": {
|
||||
"description": "Download settings page title"
|
||||
},
|
||||
@@ -508,11 +508,11 @@
|
||||
"@aboutOriginalCreator": {
|
||||
"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": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"aboutSpecialThanks": "Besonderer Dank",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
},
|
||||
@@ -520,27 +520,27 @@
|
||||
"@aboutLinks": {
|
||||
"description": "Section for external links"
|
||||
},
|
||||
"aboutMobileSource": "Mobile source code",
|
||||
"aboutMobileSource": "Mobiler Quellcode",
|
||||
"@aboutMobileSource": {
|
||||
"description": "Link to mobile GitHub repo"
|
||||
},
|
||||
"aboutPCSource": "PC source code",
|
||||
"aboutPCSource": "PC Quellcode",
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"aboutReportIssue": "Problem melden",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
},
|
||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||
"aboutReportIssueSubtitle": "Melde jedes Problem, die dir auftreten",
|
||||
"@aboutReportIssueSubtitle": {
|
||||
"description": "Subtitle for report issue"
|
||||
},
|
||||
"aboutFeatureRequest": "Feature request",
|
||||
"aboutFeatureRequest": "Feature vorschlagen",
|
||||
"@aboutFeatureRequest": {
|
||||
"description": "Link to suggest features"
|
||||
},
|
||||
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
||||
"aboutFeatureRequestSubtitle": "Schlage neue Funktionen für die App vor",
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
@@ -548,11 +548,11 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
@@ -564,11 +564,11 @@
|
||||
"@aboutVersion": {
|
||||
"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": {
|
||||
"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": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -576,7 +576,7 @@
|
||||
"@aboutDoubleDouble": {
|
||||
"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": {
|
||||
"description": "Credit for DoubleDouble API"
|
||||
},
|
||||
@@ -584,7 +584,7 @@
|
||||
"@aboutDabMusic": {
|
||||
"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": {
|
||||
"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": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,8 @@
|
||||
"@aboutOriginalCreator": {"description": "Role description for original creator"},
|
||||
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
||||
"@aboutLogoArtist": {"description": "Role description for logo artist"},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {"description": "Section for translators"},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {"description": "Section for special thanks"},
|
||||
"aboutLinks": "Links",
|
||||
@@ -1320,6 +1322,16 @@
|
||||
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
|
||||
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
|
||||
"@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": {"description": "Note about quality availability"},
|
||||
|
||||
@@ -1459,6 +1471,13 @@
|
||||
},
|
||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
||||
"@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": {"description": "Extension capability - utility functions"},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+53
-15
@@ -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": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"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
@@ -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": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,11 @@
|
||||
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
|
||||
"qualityHiResFlacMax": "Hi-Res FLAC Max",
|
||||
"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",
|
||||
|
||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||
@@ -660,6 +665,7 @@
|
||||
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
|
||||
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
|
||||
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
|
||||
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
|
||||
"folderOrganizationNone": "Tidak ada",
|
||||
|
||||
+53
-15
@@ -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": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"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
@@ -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": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"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
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"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": {
|
||||
"description": "Track count with plural form",
|
||||
"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": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -596,7 +596,7 @@
|
||||
"@albumTitle": {
|
||||
"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": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -633,7 +633,7 @@
|
||||
"@artistCompilations": {
|
||||
"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": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1108,7 +1108,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"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": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1169,7 +1169,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1376,7 +1376,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"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": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -1945,7 +1945,7 @@
|
||||
"@trackFileInfo": {
|
||||
"description": "Tab title - file information"
|
||||
},
|
||||
"trackLyrics": "Тексты песен",
|
||||
"trackLyrics": "Текст песни",
|
||||
"@trackLyrics": {
|
||||
"description": "Tab title - lyrics"
|
||||
},
|
||||
@@ -1961,7 +1961,7 @@
|
||||
"@trackOpenInSpotify": {
|
||||
"description": "Action - open track in Spotify app"
|
||||
},
|
||||
"trackTrackName": "Название трека",
|
||||
"trackTrackName": "Название",
|
||||
"@trackTrackName": {
|
||||
"description": "Metadata label - track title"
|
||||
},
|
||||
@@ -2520,7 +2520,7 @@
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"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": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2559,7 +2559,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"@homeSupports": {
|
||||
"description": "Info text about supported URL types"
|
||||
},
|
||||
"homeRecent": "Recent",
|
||||
"homeRecent": "最新的",
|
||||
"@homeRecent": {
|
||||
"description": "Section header for recent searches"
|
||||
},
|
||||
|
||||
@@ -1,48 +1,30 @@
|
||||
// GENERATED FILE - DO NOT EDIT
|
||||
// Generated by: dart run tool/check_translations.dart 0
|
||||
// Only languages with >= 0% translation completion are included.
|
||||
// Generated by: dart run tool/check_translations.dart 70
|
||||
// Only languages with >= 70% translation completion are included.
|
||||
// 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';
|
||||
|
||||
/// Minimum translation completion threshold used to filter languages.
|
||||
const int translationThreshold = 0;
|
||||
const int translationThreshold = 70;
|
||||
|
||||
/// List of locales that meet the translation threshold.
|
||||
/// Only these languages will be available in the app.
|
||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('ru'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('id'),
|
||||
Locale('ja'),
|
||||
Locale('de'),
|
||||
Locale('es'),
|
||||
Locale('fr'),
|
||||
Locale('hi'),
|
||||
Locale('ko'),
|
||||
Locale('nl'),
|
||||
Locale('pt'),
|
||||
Locale('zh'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
Locale('pt', 'PT'),
|
||||
];
|
||||
|
||||
/// Set of locale codes for quick lookup.
|
||||
const Set<String> filteredLocaleCodes = <String>{
|
||||
'en',
|
||||
'ru',
|
||||
'es_ES',
|
||||
'id',
|
||||
'ja',
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'hi',
|
||||
'ko',
|
||||
'nl',
|
||||
'pt',
|
||||
'zh',
|
||||
'zh_CN',
|
||||
'zh_TW',
|
||||
'pt_PT',
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ class AppSettings {
|
||||
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
final String locale; // App language: 'system', 'en', 'id', etc.
|
||||
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion)
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -60,6 +61,7 @@ class AppSettings {
|
||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
this.locale = 'system', // Default: follow system language
|
||||
this.enableMp3Option = false, // Default: disabled
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -91,6 +93,7 @@ class AppSettings {
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
bool? enableMp3Option,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -120,6 +123,7 @@ class AppSettings {
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -67,4 +68,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'enableMp3Option': instance.enableMp3Option,
|
||||
};
|
||||
|
||||
@@ -588,6 +588,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} 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()) {
|
||||
await coverFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
_log.w('Failed to cleanup cover file: $e');
|
||||
}
|
||||
}
|
||||
} catch (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 {
|
||||
if (state.isProcessing) return; // Prevent multiple concurrent processing
|
||||
|
||||
@@ -1440,6 +1568,35 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
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;
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
@@ -1469,6 +1626,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
source: trackToDownload.source, // Pass extension ID that provided this track
|
||||
genre: genre,
|
||||
label: label,
|
||||
);
|
||||
} else if (state.autoFallback) {
|
||||
_log.d('Using auto-fallback mode');
|
||||
@@ -1494,6 +1653,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
durationMs:
|
||||
trackToDownload.duration, // Duration in ms for verification
|
||||
genre: genre,
|
||||
label: label,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.downloadTrack(
|
||||
@@ -1543,8 +1704,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (result['success'] == true) {
|
||||
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
|
||||
_log.i('Using existing file: $filePath');
|
||||
}
|
||||
|
||||
_log.i('Download success, file: $filePath');
|
||||
@@ -1677,6 +1842,52 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
@@ -1716,6 +1927,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
? normalizedAlbumArtist
|
||||
: 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
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
@@ -1750,8 +1966,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
? backendYear
|
||||
: trackToDownload.releaseDate,
|
||||
quality: actualQuality,
|
||||
bitDepth: backendBitDepth,
|
||||
sampleRate: backendSampleRate,
|
||||
bitDepth: historyBitDepth,
|
||||
sampleRate: historySampleRate,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -355,11 +355,12 @@ class QualitySpecificSetting {
|
||||
class ExtensionSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final String type; // 'string', 'number', 'boolean', 'select', 'button'
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final bool required;
|
||||
final String? action; // For button type: JS function name to call
|
||||
|
||||
const ExtensionSetting({
|
||||
required this.key,
|
||||
@@ -369,6 +370,7 @@ class ExtensionSetting {
|
||||
this.description,
|
||||
this.options,
|
||||
this.required = false,
|
||||
this.action,
|
||||
});
|
||||
|
||||
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
||||
@@ -380,6 +382,7 @@ class ExtensionSetting {
|
||||
description: json['description'] as String?,
|
||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||
required: json['required'] as bool? ?? false,
|
||||
action: json['action'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _recentAccessKey = 'recent_access_history';
|
||||
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
|
||||
const _maxRecentItems = 20;
|
||||
|
||||
/// Types of items that can be accessed
|
||||
@@ -75,19 +76,23 @@ class RecentAccessItem {
|
||||
/// State for recent access history
|
||||
class RecentAccessState {
|
||||
final List<RecentAccessItem> items;
|
||||
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
|
||||
final bool isLoaded;
|
||||
|
||||
const RecentAccessState({
|
||||
this.items = const [],
|
||||
this.hiddenDownloadIds = const {},
|
||||
this.isLoaded = false,
|
||||
});
|
||||
|
||||
RecentAccessState copyWith({
|
||||
List<RecentAccessItem>? items,
|
||||
Set<String>? hiddenDownloadIds,
|
||||
bool? isLoaded,
|
||||
}) {
|
||||
return RecentAccessState(
|
||||
items: items ?? this.items,
|
||||
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
);
|
||||
}
|
||||
@@ -104,19 +109,27 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
Future<void> _loadHistory() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(_recentAccessKey);
|
||||
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
||||
|
||||
List<RecentAccessItem> items = [];
|
||||
Set<String> hiddenIds = {};
|
||||
|
||||
if (json != null) {
|
||||
try {
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
final items = decoded
|
||||
items = decoded
|
||||
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = state.copyWith(items: items, isLoaded: true);
|
||||
} 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 {
|
||||
@@ -125,6 +138,11 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
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
|
||||
void recordArtistAccess({
|
||||
required String id,
|
||||
@@ -200,9 +218,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
|
||||
void _recordAccess(RecentAccessItem item) {
|
||||
// ignore: avoid_print
|
||||
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
|
||||
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
.toList();
|
||||
@@ -215,9 +230,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_saveHistory();
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[RecentAccess] Total items now: ${updatedItems.length}');
|
||||
}
|
||||
|
||||
/// Remove a specific item from history
|
||||
@@ -229,11 +241,29 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
_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
|
||||
void clearHistory() {
|
||||
state = state.copyWith(items: []);
|
||||
_saveHistory();
|
||||
}
|
||||
|
||||
/// Clear hidden downloads (show all again)
|
||||
void clearHiddenDownloads() {
|
||||
state = state.copyWith(hiddenDownloadIds: {});
|
||||
_saveHiddenDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider instance
|
||||
|
||||
@@ -223,6 +223,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(locale: locale);
|
||||
_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>(
|
||||
|
||||
@@ -477,6 +477,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (e) {
|
||||
// Silently ignore availability check errors
|
||||
// This is a background operation that shouldn't disrupt the user
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+144
-60
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -60,11 +61,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
|
||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||
@@ -80,6 +86,42 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (_tracks == null) {
|
||||
_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 {
|
||||
@@ -143,6 +185,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
@@ -167,74 +210,106 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
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(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.albumName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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(
|
||||
icon: Container(
|
||||
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),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -244,6 +319,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -260,7 +337,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
widget.albumName,
|
||||
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)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
|
||||
@@ -95,11 +95,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
String? _headerImageUrl;
|
||||
int? _monthlyListeners;
|
||||
String? _error;
|
||||
|
||||
// Sticky title state
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Setup scroll listener for sticky title
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = widget.extensionId ??
|
||||
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||
@@ -141,9 +148,24 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
} else {
|
||||
_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 {
|
||||
setState(() => _isLoadingDiscography = true);
|
||||
try {
|
||||
@@ -256,8 +278,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||
|
||||
return Scaffold(
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildHeader(context, colorScheme),
|
||||
if (_isLoadingDiscography)
|
||||
@@ -307,13 +330,28 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
return SliverAppBar(
|
||||
expandedHeight: 380,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
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(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
@@ -29,15 +30,72 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
bool _isSelectionMode = false;
|
||||
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)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||
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}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()
|
||||
..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 bNum = b.trackNumber ?? 999;
|
||||
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) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
@@ -161,11 +239,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final tracks = _getAlbumTracks(allHistoryItems);
|
||||
|
||||
if (tracks.length < 2) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
// Show empty state if no tracks found
|
||||
if (tracks.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.albumName),
|
||||
),
|
||||
body: Center(
|
||||
child: Text('No tracks found for this album'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final validIds = tracks.map((t) => t.id).toSet();
|
||||
@@ -187,6 +270,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme, tracks),
|
||||
@@ -211,69 +295,98 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
|
||||
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(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.albumName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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(
|
||||
icon: Container(
|
||||
@@ -388,16 +501,84 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
// Check if album has multiple discs
|
||||
if (!_hasMultipleDiscs(tracks)) {
|
||||
// Single disc - use simple list
|
||||
return SliverList(
|
||||
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),
|
||||
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
@@ -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/platform_bridge.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/widgets/download_service_picker.dart';
|
||||
|
||||
@@ -650,17 +651,64 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
|
||||
final historyItems = ref.read(downloadHistoryProvider).items;
|
||||
|
||||
final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem(
|
||||
id: h.spotifyId!,
|
||||
name: h.trackName,
|
||||
subtitle: h.artistName,
|
||||
imageUrl: h.coverUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: h.downloadedAt,
|
||||
providerId: 'download',
|
||||
)).toList();
|
||||
// Group download history by album
|
||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
||||
for (final h in historyItems) {
|
||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||
? h.albumArtist!
|
||||
: h.artistName;
|
||||
final albumKey = '${h.albumName}|$artistForKey';
|
||||
albumGroups.putIfAbsent(albumKey, () => []).add(h);
|
||||
}
|
||||
|
||||
final 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));
|
||||
|
||||
final seen = <String>{};
|
||||
@@ -671,6 +719,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
return true;
|
||||
}).take(10).toList();
|
||||
|
||||
// Check if there are hidden downloads
|
||||
final hasHiddenDownloads = hiddenIds.isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(
|
||||
@@ -685,19 +736,53 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(recentAccessProvider.notifier).clearHistory();
|
||||
},
|
||||
child: Text(
|
||||
context.l10n.dialogClearAll,
|
||||
style: TextStyle(color: colorScheme.primary, fontSize: 12),
|
||||
if (uniqueItems.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Hide ALL download items (not just visible ones)
|
||||
for (final item in downloadItems) {
|
||||
ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
|
||||
}
|
||||
// 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),
|
||||
...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(
|
||||
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
|
||||
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:
|
||||
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(
|
||||
builder: (context) => ExtensionAlbumScreen(
|
||||
extensionId: item.providerId!,
|
||||
@@ -1311,7 +1411,19 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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
|
||||
class _TrackItemWithStatus extends ConsumerWidget {
|
||||
final Track track;
|
||||
@@ -1642,7 +1933,9 @@ class _CollectionItemWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
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),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.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';
|
||||
|
||||
/// Playlist detail screen with Material Expressive 3 design
|
||||
class PlaylistScreen extends ConsumerWidget {
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
final List<Track> tracks;
|
||||
@@ -23,16 +24,66 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
});
|
||||
|
||||
@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;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, ref, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
_buildTrackList(context, ref, colorScheme),
|
||||
_buildTrackList(context, colorScheme),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
@@ -40,59 +91,114 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
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(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (coverUrl != null)
|
||||
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||
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: coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.playlistName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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(
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -105,7 +211,7 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
@@ -115,15 +221,15 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
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),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context, ref),
|
||||
onPressed: () => _downloadAll(context),
|
||||
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))),
|
||||
),
|
||||
],
|
||||
@@ -149,25 +255,25 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
final track = widget.tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _PlaylistTrackItem(
|
||||
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);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
@@ -186,22 +292,22 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context, WidgetRef ref) {
|
||||
if (tracks.isEmpty) return;
|
||||
void _downloadAll(BuildContext context) {
|
||||
if (widget.tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: playlistName,
|
||||
trackName: '${widget.tracks.length} tracks',
|
||||
artistName: widget.playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,13 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: _TranslatorsSection(),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
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 {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
||||
@@ -694,20 +694,23 @@ class _LanguageSelector extends StatelessWidget {
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
static const _allLanguages = [
|
||||
static const _allLanguages = [
|
||||
('system', 'System Default', Icons.phone_android),
|
||||
('en', 'English', Icons.language),
|
||||
('id', 'Bahasa Indonesia', Icons.language),
|
||||
('de', 'Deutsch', Icons.language),
|
||||
('es', 'Español', Icons.language),
|
||||
('es_ES', 'Español (España)', Icons.language),
|
||||
('fr', 'Français', Icons.language),
|
||||
('hi', 'हिन्दी', Icons.language),
|
||||
('ja', '日本語', Icons.language),
|
||||
('ko', '한국어', Icons.language),
|
||||
('nl', 'Nederlands', Icons.language),
|
||||
('pt', 'Português', Icons.language),
|
||||
('pt_PT', 'Português (Portugal)', Icons.language),
|
||||
('ru', 'Русский', Icons.language),
|
||||
('zh', '简体中文', Icons.language),
|
||||
('zh_CN', '简体中文 (中国)', Icons.language),
|
||||
('zh_TW', '繁體中文', Icons.language),
|
||||
];
|
||||
|
||||
|
||||
@@ -99,6 +99,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.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) ...[
|
||||
_QualityOption(
|
||||
title: context.l10n.qualityFlacLossless,
|
||||
@@ -123,8 +134,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.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) ...[
|
||||
Padding(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_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';
|
||||
|
||||
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
||||
@@ -342,6 +343,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
value: _settings[setting.key] ?? setting.defaultValue,
|
||||
showDivider: index < extension.settings.length - 1,
|
||||
onChanged: (value) => _updateSetting(setting.key, value),
|
||||
extensionId: widget.extensionId,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -587,41 +589,62 @@ class _PermissionItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingItem extends StatelessWidget {
|
||||
class _SettingItem extends StatefulWidget {
|
||||
final ExtensionSetting setting;
|
||||
final dynamic value;
|
||||
final bool showDivider;
|
||||
final ValueChanged<dynamic> onChanged;
|
||||
final String extensionId;
|
||||
|
||||
const _SettingItem({
|
||||
required this.setting,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.extensionId,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SettingItem> createState() => _SettingItemState();
|
||||
}
|
||||
|
||||
class _SettingItemState extends State<_SettingItem> {
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
Widget trailing;
|
||||
switch (setting.type) {
|
||||
switch (widget.setting.type) {
|
||||
case 'boolean':
|
||||
trailing = Switch(
|
||||
value: value as bool? ?? false,
|
||||
onChanged: onChanged,
|
||||
value: widget.value as bool? ?? false,
|
||||
onChanged: widget.onChanged,
|
||||
);
|
||||
break;
|
||||
case 'select':
|
||||
trailing = DropdownButton<String>(
|
||||
value: value as String?,
|
||||
items: setting.options?.map((opt) {
|
||||
value: widget.value as String?,
|
||||
items: widget.setting.options?.map((opt) {
|
||||
return DropdownMenuItem(value: opt, child: Text(opt));
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
onChanged: widget.onChanged,
|
||||
underline: const SizedBox(),
|
||||
);
|
||||
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:
|
||||
trailing = Icon(
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: setting.type == 'string' || setting.type == 'number'
|
||||
onTap: widget.setting.type == 'string' || widget.setting.type == 'number'
|
||||
? () => _showEditDialog(context)
|
||||
: null,
|
||||
child: Padding(
|
||||
@@ -645,22 +709,22 @@ class _SettingItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
setting.label,
|
||||
widget.setting.label,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (setting.description != null) ...[
|
||||
if (widget.setting.description != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
setting.description!,
|
||||
widget.setting.description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (setting.type == 'string' || setting.type == 'number') ...[
|
||||
if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value?.toString() ?? 'Not set',
|
||||
widget.value?.toString() ?? 'Not set',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
@@ -674,7 +738,7 @@ class _SettingItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
if (widget.showDivider)
|
||||
Divider(
|
||||
height: 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) {
|
||||
final controller = TextEditingController(text: value?.toString() ?? '');
|
||||
final controller = TextEditingController(text: widget.value?.toString() ?? '');
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(setting.label),
|
||||
title: Text(widget.setting.label),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: setting.type == 'number'
|
||||
keyboardType: widget.setting.type == 'number'
|
||||
? TextInputType.number
|
||||
: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: setting.description ?? 'Enter value',
|
||||
hintText: widget.setting.description ?? 'Enter value',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
@@ -716,10 +825,10 @@ class _SettingItem extends StatelessWidget {
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final newValue = setting.type == 'number'
|
||||
final newValue = widget.setting.type == 'number'
|
||||
? num.tryParse(controller.text)
|
||||
: controller.text;
|
||||
onChanged(newValue);
|
||||
widget.onChanged(newValue);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.dialogSave),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -28,6 +29,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String? _lyrics;
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
String? _normalizeOptionalString(String? value) {
|
||||
if (value == null) return null;
|
||||
@@ -40,7 +44,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_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 {
|
||||
@@ -91,21 +130,48 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: _buildHeaderBackground(context, colorScheme),
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
],
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
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(
|
||||
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(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (item.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
),
|
||||
|
||||
Container(
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
colorScheme.surface.withValues(alpha: 0.8),
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Hero(
|
||||
tag: 'cover_${item.id}',
|
||||
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: item.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => Container(
|
||||
// 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: Hero(
|
||||
tag: 'cover_${item.id}',
|
||||
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: 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,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 48,
|
||||
size: 64,
|
||||
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) {
|
||||
// Determine audio quality string based on file type
|
||||
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);
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -48,18 +48,14 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
/// Convert FLAC to MP3
|
||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName =
|
||||
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';
|
||||
// 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';
|
||||
@@ -67,6 +63,12 @@ class FFmpegService {
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
// Delete original FLAC if requested
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
@@ -201,11 +203,147 @@ class FFmpegService {
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
_log.w('Failed to cleanup temp file: $e');
|
||||
}
|
||||
|
||||
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||
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
|
||||
|
||||
@@ -129,6 +129,10 @@ class PlatformBridge {
|
||||
String preferredService = 'tidal',
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
// Extended metadata for FLAC tagging
|
||||
String? genre,
|
||||
String? label,
|
||||
String? copyright,
|
||||
}) async {
|
||||
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||
final request = jsonEncode({
|
||||
@@ -151,6 +155,10 @@ class PlatformBridge {
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
// Extended metadata
|
||||
'genre': genre ?? '',
|
||||
'label': label ?? '',
|
||||
'copyright': copyright ?? '',
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||
@@ -411,6 +419,25 @@ class PlatformBridge {
|
||||
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)
|
||||
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
|
||||
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
|
||||
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
|
||||
_log.d('searchTracksWithExtensions: "$query"');
|
||||
@@ -615,6 +656,8 @@ class PlatformBridge {
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
String? source, // Extension ID that provided this track (prioritize this extension)
|
||||
String? genre,
|
||||
String? label,
|
||||
}) async {
|
||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
|
||||
final request = jsonEncode({
|
||||
@@ -637,6 +680,8 @@ class PlatformBridge {
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'source': source ?? '', // Extension ID that provided this track
|
||||
'genre': genre ?? '',
|
||||
'label': label ?? '',
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
||||
|
||||
@@ -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)
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
@@ -105,20 +112,34 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
|
||||
/// Get quality options for the selected service
|
||||
List<QualityOption> _getQualityOptions() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||
if (builtIn != null) {
|
||||
// Add MP3 option if enabled in settings
|
||||
if (settings.enableMp3Option) {
|
||||
return [...builtIn.qualityOptions, _mp3QualityOption];
|
||||
}
|
||||
return builtIn.qualityOptions;
|
||||
}
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||
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 const [
|
||||
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||
// Default fallback options
|
||||
final defaultOptions = [
|
||||
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||
];
|
||||
if (settings.enableMp3Option) {
|
||||
return [...defaultOptions, _mp3QualityOption];
|
||||
}
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -653,6 +653,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.1.1+60
|
||||
version: 3.1.2+61
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
# Material Expressive 3 / Dynamic Color
|
||||
dynamic_color: ^1.7.0
|
||||
material_color_utilities: ^0.11.1
|
||||
palette_generator: ^0.3.3+4
|
||||
|
||||
# Permissions
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.1.1+60
|
||||
version: 3.1.2+61
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
# Material Expressive 3 / Dynamic Color
|
||||
dynamic_color: ^1.7.0
|
||||
material_color_utilities: ^0.11.1
|
||||
palette_generator: ^0.3.3+4
|
||||
|
||||
# Permissions
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
Reference in New Issue
Block a user