Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46afa6e733 | |||
| c01b189477 | |||
| 966935b677 | |||
| f2f8ca4528 | |||
| 7844bd2f42 | |||
| ac3d51e2cd | |||
| b899b54bb8 | |||
| 7a17de49b2 | |||
| 79180dd918 | |||
| e725a7be77 | |||
| d960708dac | |||
| c62ad005f5 | |||
| 68fa1bfdae | |||
| bd6b23400e |
@@ -1 +1,4 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
buy_me_a_coffee: zarzet
|
||||
|
||||
|
||||
@@ -412,3 +412,125 @@ jobs:
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v}
|
||||
|
||||
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
||||
FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d')
|
||||
|
||||
if [ -z "$FULL_CHANGELOG" ]; then
|
||||
CHANGELOG="See release notes on GitHub for details."
|
||||
else
|
||||
# Convert GitHub Markdown to Telegram HTML:
|
||||
# - **text** → <b>text</b>
|
||||
# - `code` → <code>code</code>
|
||||
# - ### Header → <b>Header</b>
|
||||
# - Escape HTML special chars first
|
||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^- /• /g' | \
|
||||
sed 's/^ - / ◦ /g')
|
||||
|
||||
# Take first 2500 characters, then cut at last complete line
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
|
||||
# Check if truncated
|
||||
FULL_LEN=${#FULL_CHANGELOG}
|
||||
if [ $FULL_LEN -gt 2500 ]; then
|
||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
|
||||
- name: Send to Telegram Channel
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||
|
||||
# Find APK files
|
||||
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||
|
||||
# Prepare message with changelog (HTML format)
|
||||
printf '%s\n' \
|
||||
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||
"" \
|
||||
"<b>What's New:</b>" \
|
||||
"${CHANGELOG}" \
|
||||
"" \
|
||||
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||
> /tmp/telegram_message.txt
|
||||
|
||||
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||
|
||||
# Send message first (using HTML parse mode)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-d text="${MESSAGE}" \
|
||||
-d parse_mode="HTML" \
|
||||
-d disable_web_page_preview="true"
|
||||
|
||||
# Upload arm64 APK to channel
|
||||
if [ -f "$ARM64_APK" ]; then
|
||||
echo "Uploading arm64 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
if [ -f "$ARM32_APK" ]; then
|
||||
echo "Uploading arm32 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
if [ -f "$IOS_IPA" ]; then
|
||||
echo "Uploading iOS IPA to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
|
||||
[](https://www.virustotal.com/gui/file/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
@@ -52,6 +52,20 @@ Want to create your own extension? Check out the [Extension Development Guide](h
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
|
||||
|
||||
## Telegram
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/spotiflac">
|
||||
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
||||
</a>
|
||||
|
||||
<a href="https://t.me/spotiflacchat">
|
||||
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
@@ -69,7 +83,8 @@ A: The app needs permission to save downloaded files to your device. On Android
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkDuplicatesBatch" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"preBuildDuplicateIndex" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.preBuildDuplicateIndex(outputDir)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"invalidateDuplicateIndex" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.invalidateDuplicateIndex(outputDir)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"buildFilename" -> {
|
||||
val template = call.argument<String>("template") ?: ""
|
||||
val metadata = call.argument<String>("metadata") ?: "{}"
|
||||
@@ -306,6 +328,43 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkAvailabilityFromDeezerID" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkAvailabilityByPlatformID" -> {
|
||||
val platform = call.argument<String>("platform") ?: ""
|
||||
val entityType = call.argument<String>("entity_type") ?: ""
|
||||
val entityId = call.argument<String>("entity_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getSpotifyIDFromDeezerTrack" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getTidalURLFromDeezerTrack" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAmazonURLFromDeezerTrack" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Log methods
|
||||
"getLogs" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -468,6 +527,14 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"enrichTrackWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val trackJson = call.argument<String>("track") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"removeExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -678,6 +745,21 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension Home Feed (Explore)
|
||||
"getExtensionHomeFeed" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionHomeFeedJSON(extensionId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getExtensionBrowseCategories" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 81 KiB |
@@ -325,6 +325,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
Name: album.Title,
|
||||
ReleaseDate: album.ReleaseDate,
|
||||
Artists: artistName,
|
||||
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||
Images: albumImage,
|
||||
Genre: genreStr, // From Deezer album
|
||||
Label: album.Label, // From Deezer album
|
||||
|
||||
@@ -1720,6 +1720,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"artist_id": album.ArtistID,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
@@ -2082,3 +2083,53 @@ func ClearStoreCacheJSON() error {
|
||||
store.ClearCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
return extension.%s();
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return "", fmt.Errorf("%s returned null", functionName)
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
|
||||
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
||||
}
|
||||
|
||||
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
|
||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||
}
|
||||
|
||||
@@ -719,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
extensions := m.GetAllExtensions()
|
||||
|
||||
type ExtensionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
Types []ExtensionType `json:"types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error_message,omitempty"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
Types []ExtensionType `json:"types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error_message,omitempty"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
infos := make([]ExtensionInfo, len(extensions))
|
||||
@@ -796,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
PostProcessing: ext.Manifest.PostProcessing,
|
||||
Capabilities: ext.Manifest.Capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,24 +107,25 @@ type PostProcessingConfig struct {
|
||||
|
||||
// ExtensionManifest represents the manifest.json of an extension
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
}
|
||||
|
||||
// ManifestValidationError represents a validation error in the manifest
|
||||
|
||||
@@ -58,6 +58,7 @@ type ExtAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -371,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||
})
|
||||
|
||||
// Expose getLocalTime - returns device local time info
|
||||
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||
now := time.Now()
|
||||
_, offsetSeconds := now.Zone()
|
||||
offsetMinutes := offsetSeconds / 60
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"year": now.Year(),
|
||||
"month": int(now.Month()),
|
||||
"day": now.Day(),
|
||||
"hour": now.Hour(),
|
||||
"minute": now.Minute(),
|
||||
"second": now.Second(),
|
||||
"weekday": int(now.Weekday()),
|
||||
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
|
||||
"timezone": now.Location().String(),
|
||||
"timestamp": now.Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1072,13 +1072,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
albumName = req.AlbumName
|
||||
}
|
||||
|
||||
// Use track number from request if available, otherwise from Qobuz API
|
||||
actualTrackNumber := req.TrackNumber
|
||||
if actualTrackNumber == 0 {
|
||||
actualTrackNumber = track.TrackNumber
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: track.Title,
|
||||
Artist: track.Performer.Name,
|
||||
Album: albumName,
|
||||
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||
Date: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||
ISRC: track.ISRC,
|
||||
@@ -1135,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
Artist: track.Performer.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||
ISRC: track.ISRC,
|
||||
}, nil
|
||||
|
||||
@@ -170,6 +170,7 @@ type AlbumInfoMetadata struct {
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistId string `json:"artist_id,omitempty"`
|
||||
Images string `json:"images"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
@@ -512,11 +513,19 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
}
|
||||
|
||||
albumImage := firstImageURL(data.Images)
|
||||
|
||||
// Get first artist ID
|
||||
var firstArtistId string
|
||||
if len(data.Artists) > 0 {
|
||||
firstArtistId = data.Artists[0].ID
|
||||
}
|
||||
|
||||
info := AlbumInfoMetadata{
|
||||
TotalTracks: data.TotalTracks,
|
||||
Name: data.Name,
|
||||
ReleaseDate: data.ReleaseDate,
|
||||
Artists: joinArtists(data.Artists),
|
||||
ArtistId: firstArtistId,
|
||||
Images: albumImage,
|
||||
}
|
||||
|
||||
|
||||
@@ -331,7 +331,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
|
||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
@@ -630,7 +629,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
@@ -903,7 +902,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
|
||||
if directURL != "" {
|
||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||
if isDownloadCancelled(itemID) {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
@@ -1346,7 +1345,6 @@ func isLatinScript(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
@@ -1593,15 +1591,25 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
||||
}
|
||||
|
||||
// Use track number from request if available, otherwise from Tidal API
|
||||
actualTrackNumber := req.TrackNumber
|
||||
actualDiscNumber := req.DiscNumber
|
||||
if actualTrackNumber == 0 {
|
||||
actualTrackNumber = track.TrackNumber
|
||||
}
|
||||
if actualDiscNumber == 0 {
|
||||
actualDiscNumber = track.VolumeNumber
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: releaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: track.VolumeNumber,
|
||||
DiscNumber: actualDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
@@ -1659,8 +1667,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
Artist: track.Artist.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
DiscNumber: track.VolumeNumber,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: actualDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkDuplicatesBatch":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let outputDir = args["output_dir"] as! String
|
||||
let tracksJson = args["tracks"] as? String ?? "[]"
|
||||
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "preBuildDuplicateIndex":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let outputDir = args["output_dir"] as! String
|
||||
GobackendPreBuildDuplicateIndex(outputDir, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "invalidateDuplicateIndex":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let outputDir = args["output_dir"] as! String
|
||||
GobackendInvalidateDuplicateIndex(outputDir)
|
||||
return nil
|
||||
|
||||
case "buildFilename":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let template = args["template"] as! String
|
||||
@@ -249,6 +270,43 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailabilityFromDeezerID":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailabilityByPlatformID":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let platform = args["platform"] as! String
|
||||
let entityType = args["entity_type"] as! String
|
||||
let entityId = args["entity_id"] as! String
|
||||
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getSpotifyIDFromDeezerTrack":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getTidalURLFromDeezerTrack":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAmazonURLFromDeezerTrack":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "preWarmTrackCache":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let tracksJson = args["tracks"] as! String
|
||||
@@ -404,6 +462,14 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "enrichTrackWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let trackJson = args["track"] as? String ?? "{}"
|
||||
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "removeExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
@@ -605,6 +671,21 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
// Extension Home Feed API
|
||||
case "getExtensionHomeFeed":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getExtensionBrowseCategories":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.1.3';
|
||||
static const String buildNumber = '62';
|
||||
static const String version = '3.2.0';
|
||||
static const String buildNumber = '63';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
|
||||
import 'app_localizations_nl.dart';
|
||||
import 'app_localizations_pt.dart';
|
||||
import 'app_localizations_ru.dart';
|
||||
import 'app_localizations_tr.dart';
|
||||
import 'app_localizations_zh.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -117,6 +118,7 @@ abstract class AppLocalizations {
|
||||
Locale('pt'),
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ru'),
|
||||
Locale('tr'),
|
||||
Locale('zh'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
@@ -278,6 +280,12 @@ abstract class AppLocalizations {
|
||||
/// **'Single track downloads will appear here'**
|
||||
String get historyNoSinglesSubtitle;
|
||||
|
||||
/// Search bar placeholder in history
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search history...'**
|
||||
String get historySearchHint;
|
||||
|
||||
/// Settings screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -872,6 +880,36 @@ abstract class AppLocalizations {
|
||||
/// **'Suggest new features for the app'**
|
||||
String get aboutFeatureRequestSubtitle;
|
||||
|
||||
/// Link to Telegram channel
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Telegram Channel'**
|
||||
String get aboutTelegramChannel;
|
||||
|
||||
/// Subtitle for Telegram channel
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Announcements and updates'**
|
||||
String get aboutTelegramChannelSubtitle;
|
||||
|
||||
/// Link to Telegram chat group
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Telegram Community'**
|
||||
String get aboutTelegramChat;
|
||||
|
||||
/// Subtitle for Telegram chat
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Chat with other users'**
|
||||
String get aboutTelegramChatSubtitle;
|
||||
|
||||
/// Section for social links
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Social'**
|
||||
String get aboutSocial;
|
||||
|
||||
/// Section for support/donation links
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3751,6 +3789,108 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Error: {message}'**
|
||||
String errorGeneric(String message);
|
||||
|
||||
/// Button - download artist discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download Discography'**
|
||||
String get discographyDownload;
|
||||
|
||||
/// Option - download entire discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download All'**
|
||||
String get discographyDownloadAll;
|
||||
|
||||
/// Subtitle showing total tracks and albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks from {albumCount} releases'**
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount);
|
||||
|
||||
/// Option - download only albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Albums Only'**
|
||||
String get discographyAlbumsOnly;
|
||||
|
||||
/// Subtitle showing album tracks count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks from {albumCount} albums'**
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount);
|
||||
|
||||
/// Option - download only singles
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Singles & EPs Only'**
|
||||
String get discographySinglesOnly;
|
||||
|
||||
/// Subtitle showing singles tracks count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks from {albumCount} singles'**
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount);
|
||||
|
||||
/// Option - manually select albums to download
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select Albums...'**
|
||||
String get discographySelectAlbums;
|
||||
|
||||
/// Subtitle for select albums option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose specific albums or singles'**
|
||||
String get discographySelectAlbumsSubtitle;
|
||||
|
||||
/// Progress - fetching album tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetching tracks...'**
|
||||
String get discographyFetchingTracks;
|
||||
|
||||
/// Progress - fetching specific album
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetching {current} of {total}...'**
|
||||
String discographyFetchingAlbum(int current, int total);
|
||||
|
||||
/// Selection count badge
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} selected'**
|
||||
String discographySelectedCount(int count);
|
||||
|
||||
/// Button - download selected albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download Selected'**
|
||||
String get discographyDownloadSelected;
|
||||
|
||||
/// Snackbar - tracks added from discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {count} tracks to queue'**
|
||||
String discographyAddedToQueue(int count);
|
||||
|
||||
/// Snackbar - with skipped tracks count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{added} added, {skipped} already downloaded'**
|
||||
String discographySkippedDownloaded(int added, int skipped);
|
||||
|
||||
/// Error - no albums found for artist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No albums available'**
|
||||
String get discographyNoAlbums;
|
||||
|
||||
/// Error - some albums failed to load
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to fetch some albums'**
|
||||
String get discographyFailedToFetch;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
@@ -3775,6 +3915,7 @@ class _AppLocalizationsDelegate
|
||||
'nl',
|
||||
'pt',
|
||||
'ru',
|
||||
'tr',
|
||||
'zh',
|
||||
].contains(locale.languageCode);
|
||||
|
||||
@@ -3837,6 +3978,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
return AppLocalizationsPt();
|
||||
case 'ru':
|
||||
return AppLocalizationsRu();
|
||||
case 'tr':
|
||||
return AppLocalizationsTr();
|
||||
case 'zh':
|
||||
return AppLocalizationsZh();
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Einzelne Titel-Downloads werden hier angezeigt';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Einstellungen';
|
||||
|
||||
@@ -441,6 +444,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get aboutFeatureRequestSubtitle =>
|
||||
'Schlage neue Funktionen für die App vor';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2077,4 +2095,70 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,6 +2082,72 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Unduhan lagu satuan akan muncul di sini';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Pengaturan';
|
||||
|
||||
@@ -434,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get aboutFeatureRequestSubtitle =>
|
||||
'Sarankan fitur baru untuk aplikasi';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Dukungan';
|
||||
|
||||
@@ -2077,4 +2095,70 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Unduh Diskografi';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount rilis';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Album Saja';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount album';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Single & EP Saja';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount single';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Pilih Album...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Pilih album atau single tertentu';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Mengambil lagu...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Mengambil $current dari $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count dipilih';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Unduh yang Dipilih';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Menambahkan $count lagu ke antrian';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added ditambahkan, $skipped sudah diunduh';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'Tidak ada album tersedia';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => '設定';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,6 +2082,72 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
|
||||
@@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Здесь будут отображаться загрузки синглов';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Настройки';
|
||||
|
||||
@@ -442,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get aboutFeatureRequestSubtitle =>
|
||||
'Предложить новые функции для приложения';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Поддержка';
|
||||
|
||||
@@ -2109,4 +2127,70 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Ошибка: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -429,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -2064,6 +2082,72 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
|
||||
@@ -75,8 +75,10 @@
|
||||
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
|
||||
"historyNoSingles": "No single downloads",
|
||||
"@historyNoSingles": {"description": "Empty state when filtering singles"},
|
||||
"historyNoSinglesSubtitle": "Single track downloads will appear here",
|
||||
"historyNoSinglesSubtitle": "Single track downloads will appear here",
|
||||
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {"description": "Search bar placeholder in history"},
|
||||
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {"description": "Settings screen title"},
|
||||
@@ -304,10 +306,20 @@
|
||||
"@aboutReportIssue": {"description": "Link to report bugs"},
|
||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
|
||||
"aboutFeatureRequest": "Feature request",
|
||||
"aboutFeatureRequest": "Feature request",
|
||||
"@aboutFeatureRequest": {"description": "Link to suggest features"},
|
||||
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
||||
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {"description": "Link to Telegram channel"},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {"description": "Link to Telegram chat group"},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {"description": "Section for social links"},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
@@ -1537,5 +1549,80 @@
|
||||
"placeholders": {
|
||||
"message": {"type": "String", "description": "Error message"}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {"description": "Button - download artist discography"},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {"description": "Option - download entire discography"},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"albumCount": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {"description": "Option - download only albums"},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"albumCount": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {"description": "Option - download only singles"},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"albumCount": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {"description": "Option - manually select albums to download"},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {"description": "Progress - fetching album tracks"},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {"type": "int"},
|
||||
"skipped": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
||||
"historyTracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}",
|
||||
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbumes}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -596,7 +596,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
||||
"albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -633,7 +633,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}",
|
||||
"artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1108,7 +1108,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1169,7 +1169,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}",
|
||||
"snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1376,7 +1376,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
||||
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -1916,7 +1916,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
||||
"tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -2520,7 +2520,7 @@
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2559,7 +2559,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
||||
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
|
||||
@@ -683,5 +683,23 @@
|
||||
"recentTypePlaylist": "Playlist",
|
||||
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"errorGeneric": "Error: {message}"
|
||||
"errorGeneric": "Error: {message}",
|
||||
|
||||
"discographyDownload": "Unduh Diskografi",
|
||||
"discographyDownloadAll": "Unduh Semua",
|
||||
"discographyDownloadAllSubtitle": "{count} lagu dari {albumCount} rilis",
|
||||
"discographyAlbumsOnly": "Album Saja",
|
||||
"discographyAlbumsOnlySubtitle": "{count} lagu dari {albumCount} album",
|
||||
"discographySinglesOnly": "Single & EP Saja",
|
||||
"discographySinglesOnlySubtitle": "{count} lagu dari {albumCount} single",
|
||||
"discographySelectAlbums": "Pilih Album...",
|
||||
"discographySelectAlbumsSubtitle": "Pilih album atau single tertentu",
|
||||
"discographyFetchingTracks": "Mengambil lagu...",
|
||||
"discographyFetchingAlbum": "Mengambil {current} dari {total}...",
|
||||
"discographySelectedCount": "{count} dipilih",
|
||||
"discographyDownloadSelected": "Unduh yang Dipilih",
|
||||
"discographyAddedToQueue": "Menambahkan {count} lagu ke antrian",
|
||||
"discographySkippedDownloaded": "{added} ditambahkan, {skipped} sudah diunduh",
|
||||
"discographyNoAlbums": "Tidak ada album tersedia",
|
||||
"discographyFailedToFetch": "Gagal mengambil beberapa album"
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
||||
"historyTracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}",
|
||||
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbuns}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -596,7 +596,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
||||
"albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -633,7 +633,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}",
|
||||
"artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1376,7 +1376,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}",
|
||||
"selectionDeleteTracks": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -1916,7 +1916,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
||||
"tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"@@locale": "tr",
|
||||
"@@last_modified": "2026-01-21",
|
||||
|
||||
"appName": "SpotiFLAC",
|
||||
"@appName": {"description": "App name - DO NOT TRANSLATE"}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('DownloadQueue');
|
||||
@@ -130,15 +131,36 @@ class DownloadHistoryItem {
|
||||
class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Set<String> _downloadedSpotifyIds;
|
||||
final Map<String, DownloadHistoryItem> _bySpotifyId;
|
||||
final Map<String, DownloadHistoryItem> _byIsrc;
|
||||
|
||||
DownloadHistoryState({this.items = const []})
|
||||
: _downloadedSpotifyIds = items
|
||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
.map((item) => item.spotifyId!)
|
||||
.toSet();
|
||||
.toSet(),
|
||||
_bySpotifyId = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.spotifyId!, item)),
|
||||
),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
/// O(1) check if spotify_id exists
|
||||
bool isDownloaded(String spotifyId) =>
|
||||
_downloadedSpotifyIds.contains(spotifyId);
|
||||
|
||||
/// O(1) lookup by spotify_id
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
||||
_bySpotifyId[spotifyId];
|
||||
|
||||
/// O(1) lookup by ISRC
|
||||
DownloadHistoryItem? getByIsrc(String isrc) =>
|
||||
_byIsrc[isrc];
|
||||
|
||||
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||
return DownloadHistoryState(items: items ?? this.items);
|
||||
@@ -146,130 +168,58 @@ class DownloadHistoryState {
|
||||
}
|
||||
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const _storageKey = 'download_history';
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
DownloadHistoryState build() {
|
||||
_loadFromStorageSync();
|
||||
_loadFromDatabaseSync();
|
||||
return DownloadHistoryState();
|
||||
}
|
||||
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
void _loadFromStorageSync() {
|
||||
void _loadFromDatabaseSync() {
|
||||
if (_isLoaded) return;
|
||||
Future.microtask(() async {
|
||||
await _loadFromStorage();
|
||||
await _loadFromDatabase();
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadFromStorage() async {
|
||||
Future<void> _loadFromDatabase() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final jsonStr = prefs.getString(_storageKey);
|
||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
final items = jsonList
|
||||
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final deduplicatedItems = _deduplicateHistory(items);
|
||||
|
||||
state = state.copyWith(items: deduplicatedItems);
|
||||
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
|
||||
|
||||
if (deduplicatedItems.length < items.length) {
|
||||
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
|
||||
await _saveToStorage();
|
||||
}
|
||||
} else {
|
||||
_historyLog.d('No history found in storage');
|
||||
}
|
||||
} catch (e) {
|
||||
_historyLog.e('Failed to load history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the most recent entry (first occurrence since list is sorted by date desc)
|
||||
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
|
||||
final seen = <String, int>{}; // key -> index of first occurrence
|
||||
final result = <DownloadHistoryItem>[];
|
||||
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
String? key;
|
||||
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||
if (item.spotifyId!.startsWith('deezer:')) {
|
||||
key = 'deezer:${item.spotifyId!.substring(7)}';
|
||||
} else {
|
||||
key = 'spotify:${item.spotifyId}';
|
||||
}
|
||||
} else if (item.isrc != null && item.isrc!.isNotEmpty) {
|
||||
key = 'isrc:${item.isrc}';
|
||||
final migrated = await _db.migrateFromSharedPreferences();
|
||||
if (migrated) {
|
||||
_historyLog.i('Migrated history from SharedPreferences to SQLite');
|
||||
}
|
||||
|
||||
if (key != null) {
|
||||
if (!seen.containsKey(key)) {
|
||||
seen[key] = result.length;
|
||||
result.add(item);
|
||||
} else {
|
||||
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
||||
}
|
||||
} else {
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final jsonList = state.items.map((e) => e.toJson()).toList();
|
||||
await prefs.setString(_storageKey, jsonEncode(jsonList));
|
||||
_historyLog.d('Saved ${state.items.length} items to storage');
|
||||
} catch (e) {
|
||||
_historyLog.e('Failed to save history: $e');
|
||||
final jsonList = await _db.getAll();
|
||||
final items = jsonList
|
||||
.map((e) => DownloadHistoryItem.fromJson(e))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
_historyLog.i('Loaded ${items.length} items from SQLite database');
|
||||
} catch (e, stack) {
|
||||
_historyLog.e('Failed to load history from database: $e', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadFromStorage() async {
|
||||
await _loadFromStorage();
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
void addToHistory(DownloadHistoryItem item) {
|
||||
final existingIndex = state.items.indexWhere((existing) {
|
||||
if (item.spotifyId != null &&
|
||||
item.spotifyId!.isNotEmpty &&
|
||||
existing.spotifyId == item.spotifyId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
|
||||
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
|
||||
final itemDeezerId = item.spotifyId!.substring(7);
|
||||
final existingDeezerId = existing.spotifyId!.substring(7);
|
||||
if (itemDeezerId == existingDeezerId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.isrc != null &&
|
||||
item.isrc!.isNotEmpty &&
|
||||
existing.isrc == item.isrc) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
DownloadHistoryItem? existing;
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||
existing = state.getBySpotifyId(item.spotifyId!);
|
||||
}
|
||||
if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) {
|
||||
existing = state.getByIsrc(item.isrc!);
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
final updatedItems = [...state.items];
|
||||
updatedItems[existingIndex] = item;
|
||||
updatedItems.removeAt(existingIndex);
|
||||
if (existing != null) {
|
||||
final updatedItems = state.items.where((i) => i.id != existing!.id).toList();
|
||||
updatedItems.insert(0, item);
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||
@@ -277,31 +227,60 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
state = state.copyWith(items: [item, ...state.items]);
|
||||
_historyLog.d('Added new history entry: ${item.trackName}');
|
||||
}
|
||||
_saveToStorage();
|
||||
|
||||
_db.upsert(item.toJson()).catchError((e) {
|
||||
_historyLog.e('Failed to save to database: $e');
|
||||
});
|
||||
}
|
||||
|
||||
void removeFromHistory(String id) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.id != id).toList(),
|
||||
);
|
||||
_saveToStorage();
|
||||
_db.deleteById(id).catchError((e) {
|
||||
_historyLog.e('Failed to delete from database: $e');
|
||||
});
|
||||
}
|
||||
|
||||
void removeBySpotifyId(String spotifyId) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
|
||||
);
|
||||
_saveToStorage();
|
||||
_db.deleteBySpotifyId(spotifyId).catchError((e) {
|
||||
_historyLog.e('Failed to delete from database: $e');
|
||||
});
|
||||
_historyLog.d('Removed item with spotifyId: $spotifyId');
|
||||
}
|
||||
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
|
||||
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
|
||||
return state.getBySpotifyId(spotifyId);
|
||||
}
|
||||
|
||||
/// O(1) lookup by ISRC
|
||||
DownloadHistoryItem? getByIsrc(String isrc) {
|
||||
return state.getByIsrc(isrc);
|
||||
}
|
||||
|
||||
/// Async version with database lookup (for cases where in-memory might be stale)
|
||||
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
|
||||
final inMemory = state.getBySpotifyId(spotifyId);
|
||||
if (inMemory != null) return inMemory;
|
||||
|
||||
final json = await _db.getBySpotifyId(spotifyId);
|
||||
if (json == null) return null;
|
||||
return DownloadHistoryItem.fromJson(json);
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
state = DownloadHistoryState();
|
||||
_saveToStorage();
|
||||
_db.clearAll().catchError((e) {
|
||||
_historyLog.e('Failed to clear database: $e');
|
||||
});
|
||||
}
|
||||
|
||||
/// Get database stats for debugging
|
||||
Future<int> getDatabaseCount() async {
|
||||
return await _db.getCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +769,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String _sanitizeFolderName(String name) {
|
||||
return name
|
||||
.replaceAll(_invalidFolderChars, '_')
|
||||
.replaceAll(_trailingDotsRegex, '') // Remove trailing dots
|
||||
.replaceAll(_trailingDotsRegex, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -1067,8 +1046,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
/// Same logic as Go backend cover.go
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
||||
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
||||
const spotifySize300 = 'ab67616d00001e02';
|
||||
const spotifySize640 = 'ab67616d0000b273';
|
||||
const spotifySizeMax = 'ab67616d000082c1';
|
||||
|
||||
var result = coverUrl;
|
||||
@@ -1655,7 +1634,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
final quality = item.qualityOverride ?? state.audioQuality;
|
||||
|
||||
// Fetch extended metadata (genre, label) from Deezer if available
|
||||
// Fetch extended metadata (genre, label) from Deezer if available
|
||||
String? genre;
|
||||
String? label;
|
||||
|
||||
@@ -1667,6 +1646,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
deezerTrackId = trackToDownload.availability!.deezerId;
|
||||
}
|
||||
|
||||
if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) {
|
||||
try {
|
||||
_log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
|
||||
final deezerResult = await PlatformBridge.searchDeezerByISRC(trackToDownload.isrc!);
|
||||
if (deezerResult['success'] == true && deezerResult['track_id'] != null) {
|
||||
deezerTrackId = deezerResult['track_id'].toString();
|
||||
_log.d('Found Deezer track ID via ISRC: $deezerTrackId');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to search Deezer by ISRC: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
|
||||
try {
|
||||
final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId);
|
||||
@@ -1758,9 +1750,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
durationMs:
|
||||
trackToDownload.duration, // Duration in ms for verification
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1800,7 +1791,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||
String actualQuality = quality; // Default to requested quality
|
||||
String actualQuality = quality;
|
||||
|
||||
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
/// Represents an item in a Spotify home section
|
||||
class ExploreItem {
|
||||
final String id;
|
||||
final String uri;
|
||||
final String type; // track, album, playlist, artist, station
|
||||
final String name;
|
||||
final String artists;
|
||||
final String? description;
|
||||
final String? coverUrl;
|
||||
final String? providerId;
|
||||
final String? albumId;
|
||||
final String? albumName;
|
||||
final int durationMs;
|
||||
|
||||
const ExploreItem({
|
||||
required this.id,
|
||||
required this.uri,
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.artists,
|
||||
this.description,
|
||||
this.coverUrl,
|
||||
this.providerId,
|
||||
this.albumId,
|
||||
this.albumName,
|
||||
this.durationMs = 0,
|
||||
});
|
||||
|
||||
factory ExploreItem.fromJson(Map<String, dynamic> json) {
|
||||
return ExploreItem(
|
||||
id: json['id'] as String? ?? '',
|
||||
uri: json['uri'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'track',
|
||||
name: json['name'] as String? ?? '',
|
||||
artists: json['artists'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
coverUrl: json['cover_url'] as String?,
|
||||
providerId: json['provider_id'] as String?,
|
||||
albumId: json['album_id'] as String?,
|
||||
albumName: json['album_name'] as String?,
|
||||
durationMs: json['duration_ms'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a section in Spotify home feed
|
||||
class ExploreSection {
|
||||
final String uri;
|
||||
final String title;
|
||||
final List<ExploreItem> items;
|
||||
|
||||
const ExploreSection({
|
||||
required this.uri,
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
factory ExploreSection.fromJson(Map<String, dynamic> json) {
|
||||
final itemsList = json['items'] as List<dynamic>? ?? [];
|
||||
return ExploreSection(
|
||||
uri: json['uri'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
items: itemsList
|
||||
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for explore/home feed
|
||||
class ExploreState {
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final String? greeting;
|
||||
final List<ExploreSection> sections;
|
||||
final DateTime? lastFetched;
|
||||
|
||||
const ExploreState({
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.greeting,
|
||||
this.sections = const [],
|
||||
this.lastFetched,
|
||||
});
|
||||
|
||||
bool get hasContent => sections.isNotEmpty;
|
||||
|
||||
ExploreState copyWith({
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
String? greeting,
|
||||
List<ExploreSection>? sections,
|
||||
DateTime? lastFetched,
|
||||
}) {
|
||||
return ExploreState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
greeting: greeting ?? this.greeting,
|
||||
sections: sections ?? this.sections,
|
||||
lastFetched: lastFetched ?? this.lastFetched,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for explore/home feed state
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
@override
|
||||
ExploreState build() {
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
// Don't refetch if we have data and it's less than 5 minutes old
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
_log.d('Home feed fetch already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Find any extension with homeFeed capability
|
||||
final extState = ref.read(extensionProvider);
|
||||
_log.d('Extensions count: ${extState.extensions.length}');
|
||||
|
||||
// Look for extensions with homeFeed capability (prefer spotify-web)
|
||||
Extension? targetExt;
|
||||
for (final extension in extState.extensions) {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
continue;
|
||||
}
|
||||
if (targetExt == null || extension.id == 'spotify-web') {
|
||||
targetExt = extension;
|
||||
if (extension.id == 'spotify-web') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetExt == null) {
|
||||
_log.w('No extension with homeFeed capability found');
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'No extension with home feed support enabled',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||
|
||||
if (result == null) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to fetch home feed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final success = result['success'] as bool? ?? false;
|
||||
_log.d('getExtensionHomeFeed success=$success');
|
||||
if (!success) {
|
||||
final error = result['error'] as String? ?? 'Unknown error';
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final greeting = result['greeting'] as String?;
|
||||
final sectionsData = result['sections'] as List<dynamic>? ?? [];
|
||||
|
||||
final sections = sectionsData
|
||||
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
// Debug: log first section items
|
||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||
final firstItem = sections.first.items.first;
|
||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||
}
|
||||
|
||||
state = ExploreState(
|
||||
isLoading: false,
|
||||
greeting: greeting,
|
||||
sections: sections,
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cached data
|
||||
void clear() {
|
||||
state = const ExploreState();
|
||||
}
|
||||
|
||||
/// Refresh home feed
|
||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
|
||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||
return ExploreNotifier();
|
||||
});
|
||||
@@ -26,6 +26,7 @@ class Extension {
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -48,6 +49,7 @@ class Extension {
|
||||
this.urlHandler,
|
||||
this.trackMatching,
|
||||
this.postProcessing,
|
||||
this.capabilities = const {},
|
||||
});
|
||||
|
||||
factory Extension.fromJson(Map<String, dynamic> json) {
|
||||
@@ -84,6 +86,7 @@ class Extension {
|
||||
postProcessing: json['post_processing'] != null
|
||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||
: null,
|
||||
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +111,7 @@ class Extension {
|
||||
URLHandler? urlHandler,
|
||||
TrackMatching? trackMatching,
|
||||
PostProcessing? postProcessing,
|
||||
Map<String, dynamic>? capabilities,
|
||||
}) {
|
||||
return Extension(
|
||||
id: id ?? this.id,
|
||||
@@ -130,6 +134,7 @@ class Extension {
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
postProcessing: postProcessing ?? this.postProcessing,
|
||||
capabilities: capabilities ?? this.capabilities,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +142,8 @@ class Extension {
|
||||
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||
}
|
||||
|
||||
class SearchBehavior {
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -12,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
|
||||
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
@@ -43,6 +45,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final List<Track>? tracks; // Optional - will fetch if null
|
||||
final String? extensionId; // If from extension
|
||||
final String? artistId; // Artist ID for navigation
|
||||
final String? artistName; // Artist name for navigation
|
||||
|
||||
const AlbumScreen({
|
||||
super.key,
|
||||
@@ -50,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
this.tracks,
|
||||
this.extensionId,
|
||||
this.artistId,
|
||||
this.artistName,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -62,6 +70,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
String? _error;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
String? _artistId;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
@@ -78,10 +87,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||
_artistId = widget.artistId; // Use provided artist ID if available
|
||||
|
||||
if (_tracks == null) {
|
||||
_fetchTracks();
|
||||
}
|
||||
@@ -103,25 +114,33 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null) return;
|
||||
try {
|
||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(widget.coverUrl!),
|
||||
maximumColorCount: 16,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_dominantColor = paletteGenerator.dominantColor?.color ??
|
||||
paletteGenerator.vibrantColor?.color ??
|
||||
paletteGenerator.mutedColor?.color;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null) {
|
||||
setState(() => _dominantColor = color);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
String _formatReleaseDate(String date) {
|
||||
// Handle formats: "2024-01-15", "2024-01", "2024"
|
||||
if (date.length >= 10) {
|
||||
// Full date: 2024-01-15
|
||||
final parts = date.substring(0, 10).split('-');
|
||||
if (parts.length == 3) {
|
||||
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
|
||||
}
|
||||
} else if (date.length >= 7) {
|
||||
// Month: 2024-01
|
||||
final parts = date.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[1]}/${parts[0]}'; // MM/YYYY
|
||||
}
|
||||
}
|
||||
return date; // Year only or unknown format
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
Map<String, dynamic> metadata;
|
||||
@@ -137,11 +156,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
// Extract artist ID from album_info if available
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = albumInfo?['artist_id'] as String?;
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -310,9 +334,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -332,32 +357,59 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
if (artistName != null && artistName.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
artistName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToArtist(context, artistName),
|
||||
child: Text(
|
||||
artistName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (tracks.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (releaseDate != null && releaseDate.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -436,10 +488,51 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToArtist(BuildContext context, String artistName) {
|
||||
// Use stored artist ID if available, otherwise use a placeholder
|
||||
final artistId = _artistId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
||||
|
||||
// Don't navigate if artist ID is unknown
|
||||
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Artist information not available')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If from extension, use ExtensionArtistScreen
|
||||
if (widget.extensionId != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
extensionId: widget.extensionId!,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistScreen(
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
@@ -534,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
|
||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.trackNumber ?? 0}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Simple in-memory cache for artist data
|
||||
class _ArtistCache {
|
||||
@@ -100,6 +102,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
// Selection mode state
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedAlbumIds = {};
|
||||
bool _isFetchingDiscography = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -278,11 +285,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelectionMode) {
|
||||
_exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildHeader(context, colorScheme),
|
||||
_buildHeader(context, colorScheme, albums: albums, hasDiscography: hasDiscography),
|
||||
if (_isLoadingDiscography)
|
||||
const SliverToBoxAdapter(child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
@@ -303,13 +321,444 @@ return Scaffold(
|
||||
if (compilations.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
// Add padding at bottom for selection bar
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
],
|
||||
),
|
||||
// Selection action bar
|
||||
if (_isSelectionMode)
|
||||
_buildSelectionBar(context, colorScheme, albums),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
void _exitSelectionMode() {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedAlbumIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String albumId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
_selectedAlbumIds.add(albumId);
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleAlbumSelection(String albumId) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
if (_selectedAlbumIds.contains(albumId)) {
|
||||
_selectedAlbumIds.remove(albumId);
|
||||
if (_selectedAlbumIds.isEmpty) {
|
||||
_isSelectionMode = false;
|
||||
}
|
||||
} else {
|
||||
_selectedAlbumIds.add(albumId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAll(List<ArtistAlbum> albums) {
|
||||
setState(() {
|
||||
_selectedAlbumIds.addAll(albums.map((a) => a.id));
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAlbumIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildSelectionBar(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> allAlbums) {
|
||||
final allSelected = _selectedAlbumIds.length == allAlbums.length;
|
||||
final selectedCount = _selectedAlbumIds.length;
|
||||
final selectedAlbums = allAlbums.where((a) => _selectedAlbumIds.contains(a.id)).toList();
|
||||
final totalTracks = selectedAlbums.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Close button
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Selection info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Select all / Deselect button
|
||||
TextButton(
|
||||
onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums),
|
||||
child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Download button
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownloadSelected),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDiscographyOptions(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> albums) {
|
||||
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||
|
||||
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
final albumTracks = albumsOnly.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Title
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.download, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.l10n.discographyDownload,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Options
|
||||
if (albums.isNotEmpty)
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.library_music,
|
||||
title: context.l10n.discographyDownloadAll,
|
||||
subtitle: context.l10n.discographyDownloadAllSubtitle(totalTracks, albums.length),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_downloadAlbums(context, albums);
|
||||
},
|
||||
),
|
||||
if (albumsOnly.isNotEmpty)
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.album,
|
||||
title: context.l10n.discographyAlbumsOnly,
|
||||
subtitle: context.l10n.discographyAlbumsOnlySubtitle(albumTracks, albumsOnly.length),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_downloadAlbums(context, albumsOnly);
|
||||
},
|
||||
),
|
||||
if (singles.isNotEmpty)
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.music_note,
|
||||
title: context.l10n.discographySinglesOnly,
|
||||
subtitle: context.l10n.discographySinglesOnlySubtitle(singleTracks, singles.length),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_downloadAlbums(context, singles);
|
||||
},
|
||||
),
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.checklist,
|
||||
title: context.l10n.discographySelectAlbums,
|
||||
subtitle: context.l10n.discographySelectAlbumsSubtitle,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_enterSelectionMode(albums.first.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downloadAlbums(BuildContext context, List<ArtistAlbum> albums) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
onSelect: (quality, service) {
|
||||
_fetchAndQueueAlbums(albums, service, quality);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
_fetchAndQueueAlbums(albums, settings.defaultService, null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadSelectedAlbums(BuildContext context, List<ArtistAlbum> albums) async {
|
||||
_exitSelectionMode();
|
||||
await _downloadAlbums(context, albums);
|
||||
}
|
||||
|
||||
Future<void> _fetchAndQueueAlbums(
|
||||
List<ArtistAlbum> albums,
|
||||
String service,
|
||||
String? qualityOverride,
|
||||
) async {
|
||||
if (_isFetchingDiscography) return;
|
||||
|
||||
setState(() => _isFetchingDiscography = true);
|
||||
|
||||
// Show progress dialog
|
||||
if (!mounted) {
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _FetchingProgressDialog(
|
||||
totalAlbums: albums.length,
|
||||
onCancel: () {
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final allTracks = <Track>[];
|
||||
int fetchedCount = 0;
|
||||
int failedCount = 0;
|
||||
|
||||
// Fetch tracks from each album
|
||||
for (final album in albums) {
|
||||
if (!_isFetchingDiscography) break; // Cancelled
|
||||
|
||||
try {
|
||||
final tracks = await _fetchAlbumTracks(album);
|
||||
allTracks.addAll(tracks);
|
||||
} catch (e) {
|
||||
failedCount++;
|
||||
}
|
||||
|
||||
fetchedCount++;
|
||||
|
||||
// Update progress dialog
|
||||
if (mounted) {
|
||||
_FetchingProgressDialog.updateProgress(context, fetchedCount, albums.length);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
|
||||
// Close progress dialog
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
// Show warning if some albums failed
|
||||
if (failedCount > 0 && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
|
||||
);
|
||||
}
|
||||
|
||||
if (allTracks.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.discographyNoAlbums)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which tracks are already downloaded
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in allTracks) {
|
||||
final isDownloaded = historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null);
|
||||
|
||||
if (!isDownloaded) {
|
||||
tracksToQueue.add(track);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.discographySkippedDownloaded(0, skippedCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: qualityOverride,
|
||||
);
|
||||
|
||||
// Show success message
|
||||
if (mounted) {
|
||||
final message = skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount)
|
||||
: context.l10n.discographyAddedToQueue(tracksToQueue.length);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.snackbarViewQueue,
|
||||
onPressed: () {
|
||||
// Navigate to queue tab (index 1)
|
||||
// This will be handled by the navigation system
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
|
||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||
// Extension album
|
||||
final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} else if (album.id.startsWith('deezer:')) {
|
||||
// Deezer album
|
||||
final deezerId = album.id.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId);
|
||||
if (metadata['tracks'] != null) {
|
||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album)).toList();
|
||||
}
|
||||
} else {
|
||||
// Spotify album
|
||||
final url = 'https://open.spotify.com/album/${album.id}';
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
// Fallback to direct Spotify metadata
|
||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
if (metadata['tracks'] != null) {
|
||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue * 1000; // Deezer returns seconds
|
||||
} else if (durationValue is double) {
|
||||
durationMs = (durationValue * 1000).toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: 'deezer:${data['id']}',
|
||||
name: (data['title'] ?? data['name'] ?? '').toString(),
|
||||
artistName: (data['artist']?['name'] ?? data['artist'] ?? widget.artistName).toString(),
|
||||
albumName: album.name,
|
||||
albumArtist: widget.artistName,
|
||||
coverUrl: album.coverUrl,
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_position'] as int? ?? data['track_number'] as int?,
|
||||
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
||||
releaseDate: album.releaseDate,
|
||||
albumType: album.albumType,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme, {
|
||||
required List<ArtistAlbum> albums,
|
||||
required bool hasDiscography,
|
||||
}) {
|
||||
String? imageUrl = _headerImageUrl;
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
imageUrl = widget.headerImageUrl;
|
||||
@@ -330,7 +779,7 @@ return Scaffold(
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 380,
|
||||
expandedHeight: hasDiscography ? 420 : 380,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
@@ -429,6 +878,26 @@ if (hasValidImage)
|
||||
),
|
||||
),
|
||||
],
|
||||
// Download Discography button
|
||||
if (hasDiscography && !_isSelectionMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _showDiscographyOptions(context, colorScheme, albums),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownload),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -739,14 +1208,29 @@ if (hasValidImage)
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||
final isSelected = _selectedAlbumIds.contains(album.id);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToAlbum(album),
|
||||
onTap: () {
|
||||
if (_isSelectionMode) {
|
||||
_toggleAlbumSelection(album.id);
|
||||
} else {
|
||||
_navigateToAlbum(album);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
if (!_isSelectionMode) {
|
||||
_enterSelectionMode(album.id);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: album.coverUrl != null
|
||||
@@ -775,6 +1259,50 @@ if (hasValidImage)
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
|
||||
),
|
||||
),
|
||||
// Selection overlay
|
||||
if (_isSelectionMode)
|
||||
Positioned.fill(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: isSelected
|
||||
? colorScheme.primary.withValues(alpha: 0.3)
|
||||
: Colors.black.withValues(alpha: 0.1),
|
||||
border: isSelected
|
||||
? Border.all(color: colorScheme.primary, width: 3)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Checkbox
|
||||
if (_isSelectionMode)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.surface.withValues(alpha: 0.9),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 18)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
@@ -886,3 +1414,143 @@ if (hasValidImage)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Option tile for discography download bottom sheet
|
||||
class _DiscographyOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DiscographyOptionTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 24),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress dialog shown while fetching album tracks
|
||||
class _FetchingProgressDialog extends StatefulWidget {
|
||||
final int totalAlbums;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _FetchingProgressDialog({
|
||||
required this.totalAlbums,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
// Static method to update progress from outside
|
||||
static void updateProgress(BuildContext context, int current, int total) {
|
||||
final state = context.findAncestorStateOfType<_FetchingProgressDialogState>();
|
||||
state?._updateProgress(current, total);
|
||||
}
|
||||
|
||||
@override
|
||||
State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState();
|
||||
}
|
||||
|
||||
class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
|
||||
int _current = 0;
|
||||
int _total = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_total = widget.totalAlbums;
|
||||
}
|
||||
|
||||
void _updateProgress(int current, int total) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_current = current;
|
||||
_total = total;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final progress = _total > 0 ? _current / _total : 0.0;
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
strokeWidth: 4,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Icon(Icons.library_music, color: colorScheme.primary, size: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.discographyFetchingTracks,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.discographyFetchingAlbum(_current, _total),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Progress bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
@@ -59,36 +59,36 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
|
||||
|
||||
// Only use network images for palette extraction
|
||||
final isNetworkUrl = widget.coverUrl!.startsWith('http://') ||
|
||||
widget.coverUrl!.startsWith('https://');
|
||||
if (!isNetworkUrl) return;
|
||||
|
||||
try {
|
||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(widget.coverUrl!),
|
||||
maximumColorCount: 16,
|
||||
);
|
||||
if (mounted) {
|
||||
// Check cache first (instant)
|
||||
final cached = PaletteService.instance.getCached(widget.coverUrl);
|
||||
if (cached != null) {
|
||||
if (mounted && cached != _dominantColor) {
|
||||
setState(() {
|
||||
_dominantColor = paletteGenerator.dominantColor?.color ??
|
||||
paletteGenerator.vibrantColor?.color ??
|
||||
paletteGenerator.mutedColor?.color;
|
||||
_dominantColor = cached;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract in isolate (non-blocking)
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null && color != _dominantColor) {
|
||||
setState(() {
|
||||
_dominantColor = color;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Get tracks for this album from history provider (reactive)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||
return allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
: item.artistName;
|
||||
final itemKey = '${item.albumName}|$itemArtist';
|
||||
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||
// Use lowercase for case-insensitive matching
|
||||
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
@@ -33,6 +34,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
bool _isTyping = false;
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String? _lastSearchQuery;
|
||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||
late final ProviderSubscription<bool> _extensionInitSub;
|
||||
|
||||
/// Debounce timer for live search (extension-only feature)
|
||||
Timer? _liveSearchDebounce;
|
||||
@@ -57,11 +60,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
super.initState();
|
||||
_urlController.addListener(_onSearchChanged);
|
||||
_searchFocusNode.addListener(_onSearchFocusChanged);
|
||||
|
||||
_trackStateSub = ref.listenManual<TrackState>(trackProvider, (previous, next) {
|
||||
_onTrackStateChanged(previous, next);
|
||||
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
|
||||
_navigateToDetailIfNeeded();
|
||||
}
|
||||
});
|
||||
|
||||
_extensionInitSub = ref.listenManual<bool>(
|
||||
extensionProvider.select((s) => s.isInitialized),
|
||||
(previous, next) {
|
||||
if (next == true && previous != true) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _fetchExploreIfNeeded();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _fetchExploreIfNeeded() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final exploreState = ref.read(exploreProvider);
|
||||
final hasHomeFeedExtension = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasHomeFeed,
|
||||
);
|
||||
if (hasHomeFeedExtension && !exploreState.hasContent && !exploreState.isLoading) {
|
||||
ref.read(exploreProvider.notifier).fetchHomeFeed();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_liveSearchDebounce?.cancel();
|
||||
_trackStateSub.close();
|
||||
_extensionInitSub.close();
|
||||
_urlController.removeListener(_onSearchChanged);
|
||||
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
||||
_urlController.dispose();
|
||||
@@ -109,14 +143,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
} else if (text.isEmpty && _isTyping) {
|
||||
setState(() => _isTyping = false);
|
||||
_liveSearchDebounce?.cancel();
|
||||
// Don't clear provider here - it causes focus issues
|
||||
// Provider will be cleared when user explicitly clears or navigates away
|
||||
return;
|
||||
}
|
||||
|
||||
// Live search - only for extensions
|
||||
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
|
||||
// Skip if it's a URL (let user press enter for URLs)
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||
|
||||
_liveSearchDebounce?.cancel();
|
||||
@@ -142,7 +172,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
} finally {
|
||||
_isLiveSearchInProgress = false;
|
||||
|
||||
// Check if there's a pending query that was queued while we were searching
|
||||
final pending = _pendingLiveSearchQuery;
|
||||
_pendingLiveSearchQuery = null;
|
||||
|
||||
@@ -372,7 +401,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Use default settings without quality picker
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: this.context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
@@ -413,34 +441,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
ref.listen<TrackState>(trackProvider, (previous, next) {
|
||||
_onTrackStateChanged(previous, next);
|
||||
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
|
||||
_navigateToDetailIfNeeded();
|
||||
}
|
||||
});
|
||||
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
|
||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
final exploreState = ref.watch(exploreProvider);
|
||||
final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) =>
|
||||
s.extensions.any((e) => e.enabled && e.hasHomeFeed)
|
||||
));
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
|
||||
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
||||
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screenHeight = mediaQuery.size.height;
|
||||
final topPadding = mediaQuery.padding.top;
|
||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
|
||||
|
||||
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
|
||||
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
|
||||
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent;
|
||||
|
||||
if (hasActualResults && isShowingRecentAccess) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
@@ -455,9 +481,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => ref.read(exploreProvider.notifier).refresh(),
|
||||
notificationPredicate: (notification) => showExplore,
|
||||
child: CustomScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
@@ -492,7 +521,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
child: hasResults
|
||||
child: (hasResults || showExplore)
|
||||
? const SizedBox.shrink()
|
||||
: Column(
|
||||
children: [
|
||||
@@ -541,7 +570,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16),
|
||||
padding: EdgeInsets.fromLTRB(16, (hasResults || showExplore) ? 8 : 32, 16, (hasResults || showExplore) ? 8 : 16),
|
||||
child: _buildSearchBar(colorScheme),
|
||||
),
|
||||
),
|
||||
@@ -559,7 +588,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
child: (hasResults || showRecentAccess)
|
||||
child: (hasResults || showRecentAccess || showExplore)
|
||||
? const SizedBox.shrink()
|
||||
: Column(
|
||||
children: [
|
||||
@@ -584,6 +613,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
),
|
||||
|
||||
if (showExplore)
|
||||
..._buildExploreSections(exploreState, colorScheme),
|
||||
|
||||
if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
|
||||
..._buildSearchResults(
|
||||
tracks: tracks,
|
||||
searchArtists: searchArtists,
|
||||
@@ -594,6 +634,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
],
|
||||
),
|
||||
), // Close RefreshIndicator
|
||||
), // Close GestureDetector
|
||||
);
|
||||
}
|
||||
@@ -670,12 +711,397 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) {
|
||||
final greeting = exploreState.greeting;
|
||||
final hasGreeting = greeting != null && greeting.isNotEmpty;
|
||||
final sections = exploreState.sections;
|
||||
final sectionOffset = hasGreeting ? 1 : 0;
|
||||
final totalCount = sections.length + sectionOffset + 1; // + bottom padding
|
||||
|
||||
return [
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (hasGreeting && index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
greeting,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sectionIndex = index - sectionOffset;
|
||||
if (sectionIndex < sections.length) {
|
||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||
}
|
||||
|
||||
// Bottom padding
|
||||
return const SizedBox(height: 16);
|
||||
},
|
||||
childCount: totalCount,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
|
||||
final isYTMusicQuickPicks = _isYTMusicQuickPicksSection(section);
|
||||
|
||||
if (isYTMusicQuickPicks) {
|
||||
return _buildYTMusicQuickPicksSection(section, colorScheme);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Text(
|
||||
section.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 175,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: section.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = section.items[index];
|
||||
return _buildExploreItem(item, colorScheme);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _isYTMusicQuickPicksSection(ExploreSection section) {
|
||||
if (section.items.isEmpty) return false;
|
||||
if (section.items.first.providerId != 'ytmusic-spotiflac') return false;
|
||||
|
||||
for (final item in section.items) {
|
||||
if (item.type != 'track') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Build YT Music "Quick picks" style swipeable pages section
|
||||
Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) {
|
||||
const itemsPerPage = 5;
|
||||
final totalPages = (section.items.length / itemsPerPage).ceil();
|
||||
|
||||
return _QuickPicksPageView(
|
||||
section: section,
|
||||
colorScheme: colorScheme,
|
||||
itemsPerPage: itemsPerPage,
|
||||
totalPages: totalPages,
|
||||
onItemTap: _navigateToExploreItem,
|
||||
onItemMenu: _showTrackBottomSheet,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) {
|
||||
final isArtist = item.type == 'artist';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToExploreItem(item),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: isArtist ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isArtist ? 60 : 8),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 240,
|
||||
memCacheHeight: 240,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_getIconForType(item.type),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_getIconForType(item.type),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (item.artists.isNotEmpty && !isArtist)
|
||||
Text(
|
||||
item.artists,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIconForType(String type) {
|
||||
switch (type) {
|
||||
case 'track':
|
||||
return Icons.music_note;
|
||||
case 'album':
|
||||
return Icons.album;
|
||||
case 'playlist':
|
||||
return Icons.playlist_play;
|
||||
case 'artist':
|
||||
return Icons.person;
|
||||
case 'station':
|
||||
return Icons.radio;
|
||||
default:
|
||||
return Icons.music_note;
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToExploreItem(ExploreItem item) async {
|
||||
final extensionId = item.providerId ?? 'spotify-web';
|
||||
|
||||
switch (item.type) {
|
||||
case 'track':
|
||||
_showTrackBottomSheet(item);
|
||||
return;
|
||||
case 'album':
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionAlbumScreen(
|
||||
extensionId: extensionId,
|
||||
albumId: item.id,
|
||||
albumName: item.name,
|
||||
coverUrl: item.coverUrl,
|
||||
),
|
||||
));
|
||||
return;
|
||||
case 'playlist':
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionPlaylistScreen(
|
||||
extensionId: extensionId,
|
||||
playlistId: item.id,
|
||||
playlistName: item.name,
|
||||
coverUrl: item.coverUrl,
|
||||
),
|
||||
));
|
||||
return;
|
||||
case 'artist':
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
extensionId: extensionId,
|
||||
artistId: item.id,
|
||||
artistName: item.name,
|
||||
coverUrl: item.coverUrl,
|
||||
),
|
||||
));
|
||||
return;
|
||||
default:
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${item.type}: ${item.name}')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _showTrackBottomSheet(ExploreItem item) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 128,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.artists,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: Icon(Icons.download, color: colorScheme.primary),
|
||||
title: Text(context.l10n.downloadTitle),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_downloadExploreTrack(item);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Go to Album'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_navigateToTrackAlbum(item);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downloadExploreTrack(ExploreItem item) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
final track = Track(
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
artistName: item.artists,
|
||||
albumName: item.albumName ?? '',
|
||||
duration: item.durationMs ~/ 1000,
|
||||
trackNumber: 1,
|
||||
discNumber: 1,
|
||||
isrc: item.id,
|
||||
releaseDate: null,
|
||||
coverUrl: item.coverUrl,
|
||||
source: item.providerId ?? 'spotify-web',
|
||||
);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToTrackAlbum(ExploreItem item) async {
|
||||
if (item.albumId != null && item.albumId!.isNotEmpty) {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionAlbumScreen(
|
||||
extensionId: item.providerId ?? 'spotify-web',
|
||||
albumId: item.albumId!,
|
||||
albumName: item.albumName ?? 'Album',
|
||||
coverUrl: item.coverUrl,
|
||||
),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Album info not available')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRecentAccess(
|
||||
List<RecentAccessItem> items,
|
||||
List<DownloadHistoryItem> historyItems,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
// Group download history by album
|
||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
||||
for (final h in historyItems) {
|
||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||
@@ -736,7 +1162,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
return true;
|
||||
}).take(10).toList();
|
||||
|
||||
// Check if there are hidden downloads
|
||||
final hasHiddenDownloads = hiddenIds.isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
@@ -805,6 +1230,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) {
|
||||
IconData typeIcon;
|
||||
String typeLabel;
|
||||
final isDownloaded = item.providerId == 'download';
|
||||
|
||||
switch (item.type) {
|
||||
case RecentAccessType.artist:
|
||||
typeIcon = Icons.person;
|
||||
@@ -868,11 +1295,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.subtitle != null ? '$typeLabel • ${item.subtitle}' : typeLabel,
|
||||
isDownloaded
|
||||
? (item.subtitle != null ? '${context.l10n.recentTypeSong} • ${item.subtitle}' : context.l10n.recentTypeSong)
|
||||
: (item.subtitle != null ? '$typeLabel • ${item.subtitle}' : typeLabel),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
color: isDownloaded ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1534,7 +1963,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull;
|
||||
}
|
||||
|
||||
// Determine display icon
|
||||
IconData displayIcon = Icons.search;
|
||||
String? iconPath;
|
||||
if (currentExt != null) {
|
||||
@@ -1612,7 +2040,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||
// Extension providers
|
||||
...searchProviders.map((ext) => PopupMenuItem<String>(
|
||||
value: ext.id,
|
||||
child: Row(
|
||||
@@ -2018,6 +2445,8 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String? _artistId;
|
||||
String? _artistName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -2057,8 +2486,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
// Extract artist info from album response
|
||||
final artistId = result['artist_id'] as String?;
|
||||
final artistName = result['artists'] as String?;
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_artistName = artistName;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -2127,6 +2562,9 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
albumName: widget.albumName,
|
||||
coverUrl: widget.coverUrl,
|
||||
tracks: _tracks,
|
||||
extensionId: widget.extensionId,
|
||||
artistId: _artistId,
|
||||
artistName: _artistName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2422,3 +2860,183 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Swipeable Quick Picks widget with page indicator
|
||||
class _QuickPicksPageView extends StatefulWidget {
|
||||
final ExploreSection section;
|
||||
final ColorScheme colorScheme;
|
||||
final int itemsPerPage;
|
||||
final int totalPages;
|
||||
final void Function(ExploreItem) onItemTap;
|
||||
final void Function(ExploreItem) onItemMenu;
|
||||
|
||||
const _QuickPicksPageView({
|
||||
required this.section,
|
||||
required this.colorScheme,
|
||||
required this.itemsPerPage,
|
||||
required this.totalPages,
|
||||
required this.onItemTap,
|
||||
required this.onItemMenu,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_QuickPicksPageView> createState() => _QuickPicksPageViewState();
|
||||
}
|
||||
|
||||
class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
|
||||
int _currentPage = 0;
|
||||
late PageController _pageController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
widget.section.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: widget.itemsPerPage * 64.0,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.totalPages,
|
||||
onPageChanged: (page) {
|
||||
setState(() => _currentPage = page);
|
||||
},
|
||||
itemBuilder: (context, pageIndex) {
|
||||
final startIndex = pageIndex * widget.itemsPerPage;
|
||||
final endIndex = (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length);
|
||||
final pageItems = widget.section.items.sublist(startIndex, endIndex);
|
||||
|
||||
return Column(
|
||||
children: pageItems.map((item) => _buildQuickPickItem(item)).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.totalPages > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(widget.totalPages, (index) {
|
||||
final isActive = index == _currentPage;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: isActive ? 8 : 6,
|
||||
height: isActive ? 8 : 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive
|
||||
? widget.colorScheme.primary
|
||||
: widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickPickItem(ExploreItem item) {
|
||||
return InkWell(
|
||||
onTap: () => widget.onItemTap(item),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: widget.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: widget.colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: widget.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: widget.colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: widget.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (item.artists.isNotEmpty)
|
||||
Text(
|
||||
item.artists,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: widget.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: widget.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => widget.onItemMenu(item),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -55,19 +55,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null) return;
|
||||
try {
|
||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(widget.coverUrl!),
|
||||
maximumColorCount: 16,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_dominantColor = paletteGenerator.dominantColor?.color ??
|
||||
paletteGenerator.vibrantColor?.color ??
|
||||
paletteGenerator.mutedColor?.color;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null) {
|
||||
setState(() => _dominantColor = color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,12 +215,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -19,6 +21,7 @@ class _GroupedAlbum {
|
||||
final String? coverUrl;
|
||||
final List<DownloadHistoryItem> tracks;
|
||||
final DateTime latestDownload;
|
||||
final String searchKey;
|
||||
|
||||
_GroupedAlbum({
|
||||
required this.albumName,
|
||||
@@ -26,7 +29,7 @@ class _GroupedAlbum {
|
||||
this.coverUrl,
|
||||
required this.tracks,
|
||||
required this.latestDownload,
|
||||
});
|
||||
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
|
||||
String get key => '$albumName|$artistName';
|
||||
}
|
||||
@@ -45,6 +48,42 @@ class _HistoryStats {
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, List<String>> _filterHistoryInIsolate(
|
||||
Map<String, Object> payload,
|
||||
) {
|
||||
final entries = (payload['entries'] as List).cast<List>();
|
||||
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
||||
final query = (payload['query'] as String?) ?? '';
|
||||
|
||||
final allIds = <String>[];
|
||||
final albumIds = <String>[];
|
||||
final singleIds = <String>[];
|
||||
|
||||
for (final entry in entries) {
|
||||
final id = entry[0] as String;
|
||||
final albumKey = entry[1] as String;
|
||||
final searchKey = entry[2] as String;
|
||||
|
||||
if (query.isNotEmpty && !searchKey.contains(query)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allIds.add(id);
|
||||
final count = albumCounts[albumKey] ?? 0;
|
||||
if (count > 1) {
|
||||
albumIds.add(id);
|
||||
} else if (count == 1) {
|
||||
singleIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'all': allIds,
|
||||
'albums': albumIds,
|
||||
'singles': singleIds,
|
||||
};
|
||||
}
|
||||
|
||||
class QueueTab extends ConsumerStatefulWidget {
|
||||
final PageController? parentPageController;
|
||||
final int parentPageIndex;
|
||||
@@ -73,6 +112,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
||||
bool _isPageControllerInitialized = false;
|
||||
|
||||
// Search functionality
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String _searchQuery = '';
|
||||
Timer? _searchDebounce;
|
||||
List<DownloadHistoryItem>? _historyItemsCache;
|
||||
_HistoryStats? _historyStatsCache;
|
||||
final Map<String, String> _searchIndexCache = {};
|
||||
Map<String, DownloadHistoryItem> _historyItemsById = {};
|
||||
List<List<String>> _historyFilterEntries = const [];
|
||||
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
||||
List<DownloadHistoryItem>? _filterItemsCache;
|
||||
String _filterQueryCache = '';
|
||||
bool _filterRefreshScheduled = false;
|
||||
bool _isFilteringHistory = false;
|
||||
int _filterRequestId = 0;
|
||||
static const int _filterIsolateThreshold = 800;
|
||||
|
||||
|
||||
|
||||
@override
|
||||
@@ -88,12 +145,178 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_filterPageController = PageController(initialPage: initialPage);
|
||||
}
|
||||
|
||||
@override
|
||||
@override
|
||||
void dispose() {
|
||||
_filterPageController?.dispose();
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_searchDebounce?.cancel();
|
||||
final normalized = value.trim().toLowerCase();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 180), () {
|
||||
if (!mounted || _searchQuery == normalized) return;
|
||||
setState(() => _searchQuery = normalized);
|
||||
_requestFilterRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_searchDebounce?.cancel();
|
||||
if (_searchQuery.isEmpty) return;
|
||||
setState(() => _searchQuery = '');
|
||||
_requestFilterRefresh();
|
||||
}
|
||||
|
||||
void _ensureHistoryCaches(List<DownloadHistoryItem> items) {
|
||||
if (identical(items, _historyItemsCache)) return;
|
||||
_historyItemsCache = items;
|
||||
_historyStatsCache = _buildHistoryStats(items);
|
||||
_searchIndexCache
|
||||
..clear()
|
||||
..addEntries(
|
||||
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
|
||||
);
|
||||
_historyItemsById = {for (final item in items) item.id: item};
|
||||
_historyFilterEntries = List<List<String>>.generate(
|
||||
items.length,
|
||||
(index) {
|
||||
final item = items[index];
|
||||
final searchKey =
|
||||
_searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
final albumKey =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return [item.id, albumKey, searchKey];
|
||||
},
|
||||
growable: false,
|
||||
);
|
||||
_requestFilterRefresh();
|
||||
}
|
||||
|
||||
String _buildSearchKey(DownloadHistoryItem item) {
|
||||
return '${item.trackName} ${item.artistName} ${item.albumName}'
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
bool _isFilterCacheValid(List<DownloadHistoryItem> items, String query) {
|
||||
return identical(items, _filterItemsCache) && query == _filterQueryCache;
|
||||
}
|
||||
|
||||
void _requestFilterRefresh() {
|
||||
if (_filterRefreshScheduled) return;
|
||||
_filterRefreshScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_filterRefreshScheduled = false;
|
||||
if (!mounted) return;
|
||||
_scheduleHistoryFilterUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleHistoryFilterUpdate() {
|
||||
final items = _historyItemsCache;
|
||||
if (items == null) return;
|
||||
final query = _searchQuery;
|
||||
if (_isFilterCacheValid(items, query)) return;
|
||||
|
||||
final albumCounts =
|
||||
_historyStatsCache?.albumCounts ?? const <String, int>{};
|
||||
if (items.isEmpty) {
|
||||
setState(() {
|
||||
_filteredHistoryCache = const {};
|
||||
_filterItemsCache = items;
|
||||
_filterQueryCache = query;
|
||||
_isFilteringHistory = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length <= _filterIsolateThreshold) {
|
||||
final filteredAll =
|
||||
_filterHistoryItems(items, 'all', albumCounts, query);
|
||||
final filteredAlbums =
|
||||
_filterHistoryItems(items, 'albums', albumCounts, query);
|
||||
final filteredSingles =
|
||||
_filterHistoryItems(items, 'singles', albumCounts, query);
|
||||
setState(() {
|
||||
_filteredHistoryCache = {
|
||||
'all': filteredAll,
|
||||
'albums': filteredAlbums,
|
||||
'singles': filteredSingles,
|
||||
};
|
||||
_filterItemsCache = items;
|
||||
_filterQueryCache = query;
|
||||
_isFilteringHistory = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isFilteringHistory) {
|
||||
setState(() => _isFilteringHistory = true);
|
||||
}
|
||||
|
||||
final requestId = ++_filterRequestId;
|
||||
final payload = <String, Object>{
|
||||
'entries': _historyFilterEntries,
|
||||
'albumCounts': albumCounts,
|
||||
'query': query,
|
||||
};
|
||||
|
||||
compute(_filterHistoryInIsolate, payload).then((result) {
|
||||
if (!mounted || requestId != _filterRequestId) return;
|
||||
final itemsById = _historyItemsById;
|
||||
final filtered = <String, List<DownloadHistoryItem>>{};
|
||||
for (final entry in result.entries) {
|
||||
filtered[entry.key] = entry.value
|
||||
.map((id) => itemsById[id])
|
||||
.whereType<DownloadHistoryItem>()
|
||||
.toList(growable: false);
|
||||
}
|
||||
setState(() {
|
||||
_filteredHistoryCache = filtered;
|
||||
_filterItemsCache = items;
|
||||
_filterQueryCache = query;
|
||||
_isFilteringHistory = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
List<DownloadHistoryItem> _resolveHistoryItems({
|
||||
required String filterMode,
|
||||
required List<DownloadHistoryItem> allHistoryItems,
|
||||
required Map<String, int> albumCounts,
|
||||
}) {
|
||||
final query = _searchQuery;
|
||||
if (_isFilterCacheValid(allHistoryItems, query)) {
|
||||
final cached = _filteredHistoryCache[filterMode];
|
||||
if (cached != null) return cached;
|
||||
}
|
||||
if (allHistoryItems.isEmpty) return const [];
|
||||
if (query.isEmpty && filterMode == 'all') return allHistoryItems;
|
||||
if (allHistoryItems.length <= _filterIsolateThreshold) {
|
||||
return _filterHistoryItems(
|
||||
allHistoryItems,
|
||||
filterMode,
|
||||
albumCounts,
|
||||
query,
|
||||
);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool _shouldShowFilteringIndicator({
|
||||
required List<DownloadHistoryItem> allHistoryItems,
|
||||
required String filterMode,
|
||||
}) {
|
||||
if (allHistoryItems.isEmpty) return false;
|
||||
if (_searchQuery.isEmpty && filterMode == 'all') return false;
|
||||
if (allHistoryItems.length <= _filterIsolateThreshold) return false;
|
||||
return !_isFilterCacheValid(allHistoryItems, _searchQuery) ||
|
||||
_isFilteringHistory;
|
||||
}
|
||||
|
||||
void _onFilterPageChanged(int index) {
|
||||
final filterMode = _filterModes[index];
|
||||
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
|
||||
@@ -274,7 +497,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
);
|
||||
|
||||
_precacheCover(historyItem.coverUrl);
|
||||
_precacheCover(historyItem.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
@@ -285,11 +509,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
||||
_precacheCover(item.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
@@ -300,46 +525,63 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
List<DownloadHistoryItem> _filterHistoryItems(
|
||||
List<DownloadHistoryItem> _filterHistoryItems(
|
||||
List<DownloadHistoryItem> items,
|
||||
String filterMode,
|
||||
Map<String, int> albumCounts,
|
||||
) {
|
||||
if (filterMode == 'all') return items;
|
||||
Map<String, int> albumCounts, [
|
||||
String searchQuery = '',
|
||||
]) {
|
||||
// First apply search filter
|
||||
var filteredItems = items;
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery;
|
||||
filteredItems = items.where((item) {
|
||||
final searchKey =
|
||||
_searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
if (!_searchIndexCache.containsKey(item.id)) {
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
return searchKey.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
switch (filterMode) {
|
||||
// Then apply filter mode
|
||||
if (filterMode == 'all') return filteredItems;
|
||||
|
||||
switch (filterMode) {
|
||||
case 'albums':
|
||||
return items.where((item) {
|
||||
return filteredItems.where((item) {
|
||||
final key =
|
||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return (albumCounts[key] ?? 0) > 1;
|
||||
}).toList();
|
||||
case 'singles':
|
||||
return items.where((item) {
|
||||
return filteredItems.where((item) {
|
||||
final key =
|
||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return (albumCounts[key] ?? 0) == 1;
|
||||
}).toList();
|
||||
default:
|
||||
return items;
|
||||
return filteredItems;
|
||||
}
|
||||
}
|
||||
|
||||
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
|
||||
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
|
||||
final albumCounts = <String, int>{};
|
||||
final albumMap = <String, List<DownloadHistoryItem>>{};
|
||||
for (final item in items) {
|
||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
// Use lowercase key for case-insensitive grouping
|
||||
final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
|
||||
albumMap.putIfAbsent(key, () => []).add(item);
|
||||
}
|
||||
|
||||
int singleTracks = 0;
|
||||
for (final item in items) {
|
||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
if ((albumCounts[key] ?? 0) <= 1) {
|
||||
singleTracks++;
|
||||
}
|
||||
@@ -380,7 +622,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
||||
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
@@ -395,27 +638,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_initializePageController();
|
||||
|
||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
final isProcessing = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.isProcessing),
|
||||
);
|
||||
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||
final queuedCount = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.queuedCount),
|
||||
);
|
||||
final completedCount = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.completedCount),
|
||||
);
|
||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
final allHistoryItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
);
|
||||
_ensureHistoryCaches(allHistoryItems);
|
||||
final historyViewMode = ref.watch(
|
||||
settingsProvider.select((s) => s.historyViewMode),
|
||||
);
|
||||
@@ -425,7 +659,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
final historyStats = _buildHistoryStats(allHistoryItems);
|
||||
final historyStats =
|
||||
_historyStatsCache ?? _buildHistoryStats(allHistoryItems);
|
||||
final groupedAlbums = historyStats.groupedAlbums;
|
||||
final albumCount = historyStats.albumCount;
|
||||
final singleCount = historyStats.singleTracks;
|
||||
@@ -480,68 +715,82 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if ((isProcessing || queuedCount > 0) &&
|
||||
(queueItems.length > 1 || isPaused))
|
||||
// Search bar - always at top
|
||||
if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isPaused
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
isPaused ? Icons.pause : Icons.downloading,
|
||||
color: isPaused
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: false,
|
||||
canRequestFocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.historySearchHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_clearSearch();
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isPaused
|
||||
? 'Paused'
|
||||
: '$completedCount/${queueItems.length}',
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
width: 1.5,
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.togglePause(),
|
||||
child: Text(isPaused ? 'Resume' : 'Pause'),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 2.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
onTapOutside: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Text(
|
||||
'Downloading (${queueItems.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverList(
|
||||
@@ -551,7 +800,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
key: ValueKey(item.id),
|
||||
child: _buildQueueItem(context, item, colorScheme),
|
||||
);
|
||||
}, childCount: queueItems.length),
|
||||
}, childCount: queueItems.length),
|
||||
),
|
||||
|
||||
if (allHistoryItems.isNotEmpty)
|
||||
@@ -655,42 +904,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
return false;
|
||||
},
|
||||
child: PageView(
|
||||
child: PageView.builder(
|
||||
controller: _filterPageController!,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
onPageChanged: _onFilterPageChanged,
|
||||
children: [
|
||||
_buildFilterContent(
|
||||
itemCount: _filterModes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filterMode = _filterModes[index];
|
||||
return _buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: 'all',
|
||||
filterMode: filterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
historyViewMode: historyViewMode,
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
albumCounts: historyStats.albumCounts,
|
||||
),
|
||||
_buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: 'albums',
|
||||
allHistoryItems: allHistoryItems,
|
||||
historyViewMode: historyViewMode,
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
albumCounts: historyStats.albumCounts,
|
||||
),
|
||||
_buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: 'singles',
|
||||
allHistoryItems: allHistoryItems,
|
||||
historyViewMode: historyViewMode,
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
albumCounts: historyStats.albumCounts,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -702,13 +933,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(
|
||||
child: _buildSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
_filterHistoryItems(
|
||||
allHistoryItems,
|
||||
historyFilterMode,
|
||||
historyStats.albumCounts,
|
||||
_resolveHistoryItems(
|
||||
filterMode: historyFilterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
albumCounts: historyStats.albumCounts,
|
||||
),
|
||||
bottomPadding,
|
||||
),
|
||||
@@ -726,10 +957,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required String historyViewMode,
|
||||
required List<DownloadItem> queueItems,
|
||||
required List<_GroupedAlbum> groupedAlbums,
|
||||
required Map<String, int> albumCounts,
|
||||
required Map<String, int> albumCounts,
|
||||
}) {
|
||||
final historyItems =
|
||||
_filterHistoryItems(allHistoryItems, filterMode, albumCounts);
|
||||
final historyItems = _resolveHistoryItems(
|
||||
filterMode: filterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
albumCounts: albumCounts,
|
||||
);
|
||||
final showFilteringIndicator = _shouldShowFilteringIndicator(
|
||||
allHistoryItems: allHistoryItems,
|
||||
filterMode: filterMode,
|
||||
);
|
||||
|
||||
// Filter grouped albums based on search query
|
||||
final searchQuery = _searchQuery;
|
||||
final filteredGroupedAlbums = searchQuery.isEmpty
|
||||
? groupedAlbums
|
||||
: groupedAlbums
|
||||
.where((album) => album.searchKey.contains(searchQuery))
|
||||
.toList();
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -763,14 +1009,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (groupedAlbums.isNotEmpty &&
|
||||
if (filteredGroupedAlbums.isNotEmpty &&
|
||||
queueItems.isEmpty &&
|
||||
filterMode == 'albums')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}',
|
||||
'${filteredGroupedAlbums.length} ${filteredGroupedAlbums.length == 1 ? 'album' : 'albums'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -791,7 +1037,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
|
||||
if (showFilteringIndicator)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Filtering...',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverGrid(
|
||||
@@ -803,12 +1075,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final album = groupedAlbums[index];
|
||||
final album = filteredGroupedAlbums[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(album.key),
|
||||
child: _buildAlbumGridItem(context, album, colorScheme),
|
||||
);
|
||||
}, childCount: groupedAlbums.length),
|
||||
}, childCount: filteredGroupedAlbums.length),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -854,9 +1126,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}, childCount: historyItems.length ),
|
||||
),
|
||||
|
||||
if (queueItems.isEmpty &&
|
||||
if (queueItems.isEmpty &&
|
||||
historyItems.isEmpty &&
|
||||
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
||||
(filterMode != 'albums' || filteredGroupedAlbums.isEmpty) &&
|
||||
!showFilteringIndicator)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _buildEmptyState(
|
||||
|
||||
@@ -157,7 +157,7 @@ class AboutPage extends StatelessWidget {
|
||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.lightbulb_outline,
|
||||
title: context.l10n.aboutFeatureRequest,
|
||||
subtitle: context.l10n.aboutFeatureRequestSubtitle,
|
||||
@@ -168,6 +168,30 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.telegram,
|
||||
title: context.l10n.aboutTelegramChannel,
|
||||
subtitle: context.l10n.aboutTelegramChannelSubtitle,
|
||||
onTap: () => _launchUrl('https://t.me/spotiflac'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.forum_outlined,
|
||||
title: context.l10n.aboutTelegramChat,
|
||||
subtitle: context.l10n.aboutTelegramChatSubtitle,
|
||||
onTap: () => _launchUrl('https://t.me/spotiflacchat'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||
),
|
||||
|
||||
@@ -709,6 +709,7 @@ static const _allLanguages = [
|
||||
('pt', 'Português', Icons.language),
|
||||
('pt_PT', 'Português (Brasil)', Icons.language),
|
||||
('ru', 'Русский', Icons.language),
|
||||
('tr', 'Türkçe', Icons.language),
|
||||
('zh', '简体中文', Icons.language),
|
||||
('zh_CN', '简体中文 (中国)', Icons.language),
|
||||
('zh_TW', '繁體中文', Icons.language),
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
@@ -61,7 +61,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_checkFile();
|
||||
_extractDominantColor();
|
||||
// Delay palette extraction to avoid jitter during initial build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_extractDominantColor();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -80,25 +83,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
final coverUrl = widget.item.coverUrl;
|
||||
if (coverUrl == null || coverUrl.isEmpty) return;
|
||||
if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) {
|
||||
|
||||
// Check cache first
|
||||
final cachedColor = PaletteService.instance.getCached(coverUrl);
|
||||
if (cachedColor != null) {
|
||||
if (mounted && cachedColor != _dominantColor) {
|
||||
setState(() => _dominantColor = cachedColor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(coverUrl),
|
||||
size: const Size(128, 128),
|
||||
maximumColorCount: 12,
|
||||
);
|
||||
final nextColor = paletteGenerator.dominantColor?.color ??
|
||||
paletteGenerator.vibrantColor?.color ??
|
||||
paletteGenerator.mutedColor?.color;
|
||||
if (mounted && nextColor != _dominantColor) {
|
||||
setState(() {
|
||||
_dominantColor = nextColor;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
|
||||
// Extract using PaletteService (runs in isolate)
|
||||
final color = await PaletteService.instance.extractDominantColor(coverUrl);
|
||||
if (mounted && color != null && color != _dominantColor) {
|
||||
setState(() => _dominantColor = color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'dart:convert';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('HistoryDatabase');
|
||||
|
||||
/// SQLite database service for download history
|
||||
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
|
||||
class HistoryDatabase {
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
HistoryDatabase._init();
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('history.db');
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDB(String fileName) async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, fileName);
|
||||
|
||||
_log.i('Initializing database at: $path');
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createDB(Database db, int version) async {
|
||||
_log.i('Creating database schema v$version');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE history (
|
||||
id TEXT PRIMARY KEY,
|
||||
track_name TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL,
|
||||
album_artist TEXT,
|
||||
cover_url TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
downloaded_at TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
spotify_id TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
duration INTEGER,
|
||||
release_date TEXT,
|
||||
quality TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
genre TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes for fast lookups
|
||||
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
||||
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
||||
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
|
||||
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
|
||||
|
||||
_log.i('Database schema created with indexes');
|
||||
}
|
||||
|
||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
||||
// Future migrations go here
|
||||
}
|
||||
|
||||
/// Migrate data from SharedPreferences to SQLite
|
||||
/// Returns true if migration was performed, false if already migrated
|
||||
Future<bool> migrateFromSharedPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final migrationKey = 'history_migrated_to_sqlite';
|
||||
|
||||
if (prefs.getBool(migrationKey) == true) {
|
||||
_log.d('Already migrated to SQLite');
|
||||
return false;
|
||||
}
|
||||
|
||||
final jsonStr = prefs.getString('download_history');
|
||||
if (jsonStr == null || jsonStr.isEmpty) {
|
||||
_log.d('No SharedPreferences history to migrate');
|
||||
await prefs.setBool(migrationKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
|
||||
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final json in jsonList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
batch.insert(
|
||||
'history',
|
||||
_jsonToDbRow(map),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
|
||||
// Mark as migrated but keep old data for safety
|
||||
await prefs.setBool(migrationKey, true);
|
||||
_log.i('Migration complete: ${jsonList.length} items');
|
||||
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
_log.e('Migration failed: $e', e, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
'id': json['id'],
|
||||
'track_name': json['trackName'],
|
||||
'artist_name': json['artistName'],
|
||||
'album_name': json['albumName'],
|
||||
'album_artist': json['albumArtist'],
|
||||
'cover_url': json['coverUrl'],
|
||||
'file_path': json['filePath'],
|
||||
'service': json['service'],
|
||||
'downloaded_at': json['downloadedAt'],
|
||||
'isrc': json['isrc'],
|
||||
'spotify_id': json['spotifyId'],
|
||||
'track_number': json['trackNumber'],
|
||||
'disc_number': json['discNumber'],
|
||||
'duration': json['duration'],
|
||||
'release_date': json['releaseDate'],
|
||||
'quality': json['quality'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'genre': json['genre'],
|
||||
'label': json['label'],
|
||||
'copyright': json['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
return {
|
||||
'id': row['id'],
|
||||
'trackName': row['track_name'],
|
||||
'artistName': row['artist_name'],
|
||||
'albumName': row['album_name'],
|
||||
'albumArtist': row['album_artist'],
|
||||
'coverUrl': row['cover_url'],
|
||||
'filePath': row['file_path'],
|
||||
'service': row['service'],
|
||||
'downloadedAt': row['downloaded_at'],
|
||||
'isrc': row['isrc'],
|
||||
'spotifyId': row['spotify_id'],
|
||||
'trackNumber': row['track_number'],
|
||||
'discNumber': row['disc_number'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'quality': row['quality'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'genre': row['genre'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CRUD Operations ====================
|
||||
|
||||
/// Insert or update a history item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'history',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all history items ordered by download date (newest first)
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
orderBy: 'downloaded_at DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by Spotify ID - O(1) with index
|
||||
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'spotify_id = ?',
|
||||
whereArgs: [spotifyId],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by ISRC - O(1) with index
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'isrc = ?',
|
||||
whereArgs: [isrc],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Check if spotify_id exists - O(1) with index
|
||||
Future<bool> existsBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
'SELECT 1 FROM history WHERE spotify_id = ? LIMIT 1',
|
||||
[spotifyId],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get all spotify_ids as Set for fast in-memory lookup
|
||||
Future<Set<String>> getAllSpotifyIds() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
|
||||
);
|
||||
return rows.map((r) => r['spotify_id'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete by ID
|
||||
Future<void> deleteById(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Delete by Spotify ID
|
||||
Future<void> deleteBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('history');
|
||||
_log.i('Cleared all history');
|
||||
}
|
||||
|
||||
/// Get total count
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
/// Find existing item by spotify_id or isrc (for deduplication)
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? spotifyId,
|
||||
String? isrc,
|
||||
}) async {
|
||||
if (spotifyId != null && spotifyId.isNotEmpty) {
|
||||
final bySpotify = await getBySpotifyId(spotifyId);
|
||||
if (bySpotify != null) return bySpotify;
|
||||
|
||||
// Check for deezer: prefix matching
|
||||
if (spotifyId.startsWith('deezer:')) {
|
||||
final deezerId = spotifyId.substring(7);
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'spotify_id LIKE ?',
|
||||
whereArgs: ['deezer:$deezerId'],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
||||
}
|
||||
}
|
||||
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
return await getByIsrc(isrc);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
|
||||
/// Service for extracting dominant colors from images
|
||||
/// Uses caching to avoid re-extraction and small image size for speed
|
||||
class PaletteService {
|
||||
static final PaletteService instance = PaletteService._();
|
||||
PaletteService._();
|
||||
|
||||
/// Cache for already computed colors
|
||||
final Map<String, Color> _colorCache = {};
|
||||
|
||||
/// Extract dominant color from a network image URL
|
||||
/// Uses small image size and limited colors for speed
|
||||
Future<Color?> extractDominantColor(String? imageUrl) async {
|
||||
if (imageUrl == null || imageUrl.isEmpty) return null;
|
||||
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_colorCache.containsKey(imageUrl)) {
|
||||
return _colorCache[imageUrl];
|
||||
}
|
||||
|
||||
try {
|
||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
size: const Size(64, 64),
|
||||
maximumColorCount: 8,
|
||||
);
|
||||
|
||||
final color = paletteGenerator.dominantColor?.color ??
|
||||
paletteGenerator.vibrantColor?.color ??
|
||||
paletteGenerator.mutedColor?.color;
|
||||
|
||||
if (color != null) {
|
||||
_colorCache[imageUrl] = color;
|
||||
}
|
||||
|
||||
return color;
|
||||
} catch (e) {
|
||||
debugPrint('PaletteService error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the color cache
|
||||
void clearCache() {
|
||||
_colorCache.clear();
|
||||
}
|
||||
|
||||
/// Get cached color without computing
|
||||
Color? getCached(String? imageUrl) {
|
||||
if (imageUrl == null) return null;
|
||||
return _colorCache[imageUrl];
|
||||
}
|
||||
}
|
||||
@@ -794,6 +794,34 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension home feed
|
||||
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getExtensionHomeFeed failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension browse categories
|
||||
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getExtensionBrowseCategories failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static Future<Map<String, dynamic>> runPostProcessing(
|
||||
String filePath, {
|
||||
|
||||
@@ -1027,7 +1027,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.1.3+62
|
||||
version: 3.2.0+63
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -26,6 +26,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.3
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.1
|
||||
|
||||
# HTTP & Network
|
||||
http: ^1.6.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.1.3+62
|
||||
version: 3.2.0+63
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -26,6 +26,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.3
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.1
|
||||
|
||||
# HTTP & Network
|
||||
http: ^1.6.0
|
||||
|
||||