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
|
ko_fi: zarzet
|
||||||
|
buy_me_a_coffee: zarzet
|
||||||
|
|
||||||
|
|||||||
@@ -412,3 +412,125 @@ jobs:
|
|||||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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://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)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<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)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
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
|
## FAQ
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
**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?**
|
**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).
|
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
|
## Disclaimer
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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" -> {
|
"buildFilename" -> {
|
||||||
val template = call.argument<String>("template") ?: ""
|
val template = call.argument<String>("template") ?: ""
|
||||||
val metadata = call.argument<String>("metadata") ?: "{}"
|
val metadata = call.argument<String>("metadata") ?: "{}"
|
||||||
@@ -306,6 +328,43 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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
|
// Log methods
|
||||||
"getLogs" -> {
|
"getLogs" -> {
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -468,6 +527,14 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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" -> {
|
"removeExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -678,6 +745,21 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
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()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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,
|
Name: album.Title,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
Artists: artistName,
|
Artists: artistName,
|
||||||
|
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
Genre: genreStr, // From Deezer album
|
Genre: genreStr, // From Deezer album
|
||||||
Label: album.Label, // From Deezer album
|
Label: album.Label, // From Deezer album
|
||||||
|
|||||||
@@ -1720,6 +1720,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
"id": album.ID,
|
"id": album.ID,
|
||||||
"name": album.Name,
|
"name": album.Name,
|
||||||
"artists": album.Artists,
|
"artists": album.Artists,
|
||||||
|
"artist_id": album.ArtistID,
|
||||||
"cover_url": album.CoverURL,
|
"cover_url": album.CoverURL,
|
||||||
"release_date": album.ReleaseDate,
|
"release_date": album.ReleaseDate,
|
||||||
"total_tracks": album.TotalTracks,
|
"total_tracks": album.TotalTracks,
|
||||||
@@ -2082,3 +2083,53 @@ func ClearStoreCacheJSON() error {
|
|||||||
store.ClearCache()
|
store.ClearCache()
|
||||||
return nil
|
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()
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
type ExtensionInfo struct {
|
type ExtensionInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
IconPath string `json:"icon_path,omitempty"`
|
IconPath string `json:"icon_path,omitempty"`
|
||||||
Types []ExtensionType `json:"types"`
|
Types []ExtensionType `json:"types"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error string `json:"error_message,omitempty"`
|
Error string `json:"error_message,omitempty"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||||
HasDownloadProvider bool `json:"has_download_provider"`
|
HasDownloadProvider bool `json:"has_download_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
infos := make([]ExtensionInfo, len(extensions))
|
infos := make([]ExtensionInfo, len(extensions))
|
||||||
@@ -796,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
PostProcessing: ext.Manifest.PostProcessing,
|
PostProcessing: ext.Manifest.PostProcessing,
|
||||||
|
Capabilities: ext.Manifest.Capabilities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,24 +107,25 @@ type PostProcessingConfig struct {
|
|||||||
|
|
||||||
// ExtensionManifest represents the manifest.json of an extension
|
// ExtensionManifest represents the manifest.json of an extension
|
||||||
type ExtensionManifest struct {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||||
Types []ExtensionType `json:"type"`
|
Types []ExtensionType `json:"type"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
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)
|
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
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
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
|
// ManifestValidationError represents a validation error in the manifest
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type ExtAlbumMetadata struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -371,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
|
|
||||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
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
|
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{
|
metadata := Metadata{
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: albumName,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||||
Date: track.Album.ReleaseDate,
|
Date: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
@@ -1135,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: track.Album.Title,
|
Album: track.Album.Title,
|
||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ type AlbumInfoMetadata struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
|
ArtistId string `json:"artist_id,omitempty"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
@@ -512,11 +513,19 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
albumImage := firstImageURL(data.Images)
|
albumImage := firstImageURL(data.Images)
|
||||||
|
|
||||||
|
// Get first artist ID
|
||||||
|
var firstArtistId string
|
||||||
|
if len(data.Artists) > 0 {
|
||||||
|
firstArtistId = data.Artists[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: data.TotalTracks,
|
TotalTracks: data.TotalTracks,
|
||||||
Name: data.Name,
|
Name: data.Name,
|
||||||
ReleaseDate: data.ReleaseDate,
|
ReleaseDate: data.ReleaseDate,
|
||||||
Artists: joinArtists(data.Artists),
|
Artists: joinArtists(data.Artists),
|
||||||
|
ArtistId: firstArtistId,
|
||||||
Images: albumImage,
|
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)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
@@ -630,7 +629,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
|
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
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)}
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -903,7 +902,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
|
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1346,7 +1345,6 @@ func isLatinScript(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
@@ -1593,15 +1591,25 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
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{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: releaseDate,
|
Date: releaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: track.VolumeNumber,
|
DiscNumber: actualDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
@@ -1659,8 +1667,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
Artist: track.Artist.Name,
|
Artist: track.Artist.Name,
|
||||||
Album: track.Album.Title,
|
Album: track.Album.Title,
|
||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: track.VolumeNumber,
|
DiscNumber: actualDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "buildFilename":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let template = args["template"] as! String
|
let template = args["template"] as! String
|
||||||
@@ -249,6 +270,43 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "preWarmTrackCache":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let tracksJson = args["tracks"] as! String
|
let tracksJson = args["tracks"] as! String
|
||||||
@@ -404,6 +462,14 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "removeExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -605,6 +671,21 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
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:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.1.3';
|
static const String version = '3.2.0';
|
||||||
static const String buildNumber = '62';
|
static const String buildNumber = '63';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
|
|||||||
import 'app_localizations_nl.dart';
|
import 'app_localizations_nl.dart';
|
||||||
import 'app_localizations_pt.dart';
|
import 'app_localizations_pt.dart';
|
||||||
import 'app_localizations_ru.dart';
|
import 'app_localizations_ru.dart';
|
||||||
|
import 'app_localizations_tr.dart';
|
||||||
import 'app_localizations_zh.dart';
|
import 'app_localizations_zh.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -117,6 +118,7 @@ abstract class AppLocalizations {
|
|||||||
Locale('pt'),
|
Locale('pt'),
|
||||||
Locale('pt', 'PT'),
|
Locale('pt', 'PT'),
|
||||||
Locale('ru'),
|
Locale('ru'),
|
||||||
|
Locale('tr'),
|
||||||
Locale('zh'),
|
Locale('zh'),
|
||||||
Locale('zh', 'CN'),
|
Locale('zh', 'CN'),
|
||||||
Locale('zh', 'TW'),
|
Locale('zh', 'TW'),
|
||||||
@@ -278,6 +280,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Single track downloads will appear here'**
|
/// **'Single track downloads will appear here'**
|
||||||
String get historyNoSinglesSubtitle;
|
String get historyNoSinglesSubtitle;
|
||||||
|
|
||||||
|
/// Search bar placeholder in history
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search history...'**
|
||||||
|
String get historySearchHint;
|
||||||
|
|
||||||
/// Settings screen title
|
/// Settings screen title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -872,6 +880,36 @@ abstract class AppLocalizations {
|
|||||||
/// **'Suggest new features for the app'**
|
/// **'Suggest new features for the app'**
|
||||||
String get aboutFeatureRequestSubtitle;
|
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
|
/// Section for support/donation links
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3751,6 +3789,108 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Error: {message}'**
|
/// **'Error: {message}'**
|
||||||
String errorGeneric(String 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
|
class _AppLocalizationsDelegate
|
||||||
@@ -3775,6 +3915,7 @@ class _AppLocalizationsDelegate
|
|||||||
'nl',
|
'nl',
|
||||||
'pt',
|
'pt',
|
||||||
'ru',
|
'ru',
|
||||||
|
'tr',
|
||||||
'zh',
|
'zh',
|
||||||
].contains(locale.languageCode);
|
].contains(locale.languageCode);
|
||||||
|
|
||||||
@@ -3837,6 +3978,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
|||||||
return AppLocalizationsPt();
|
return AppLocalizationsPt();
|
||||||
case 'ru':
|
case 'ru':
|
||||||
return AppLocalizationsRu();
|
return AppLocalizationsRu();
|
||||||
|
case 'tr':
|
||||||
|
return AppLocalizationsTr();
|
||||||
case 'zh':
|
case 'zh':
|
||||||
return AppLocalizationsZh();
|
return AppLocalizationsZh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Einzelne Titel-Downloads werden hier angezeigt';
|
'Einzelne Titel-Downloads werden hier angezeigt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Einstellungen';
|
String get settingsTitle => 'Einstellungen';
|
||||||
|
|
||||||
@@ -441,6 +444,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get aboutFeatureRequestSubtitle =>
|
String get aboutFeatureRequestSubtitle =>
|
||||||
'Schlage neue Funktionen für die App vor';
|
'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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2077,4 +2095,70 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,6 +2082,72 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Unduhan lagu satuan akan muncul di sini';
|
'Unduhan lagu satuan akan muncul di sini';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Pengaturan';
|
String get settingsTitle => 'Pengaturan';
|
||||||
|
|
||||||
@@ -434,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get aboutFeatureRequestSubtitle =>
|
String get aboutFeatureRequestSubtitle =>
|
||||||
'Sarankan fitur baru untuk aplikasi';
|
'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
|
@override
|
||||||
String get aboutSupport => 'Dukungan';
|
String get aboutSupport => 'Dukungan';
|
||||||
|
|
||||||
@@ -2077,4 +2095,70 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => '設定';
|
String get settingsTitle => '設定';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,4 +2082,70 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,6 +2082,72 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Здесь будут отображаться загрузки синглов';
|
'Здесь будут отображаться загрузки синглов';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Настройки';
|
String get settingsTitle => 'Настройки';
|
||||||
|
|
||||||
@@ -442,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get aboutFeatureRequestSubtitle =>
|
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
|
@override
|
||||||
String get aboutSupport => 'Поддержка';
|
String get aboutSupport => 'Поддержка';
|
||||||
|
|
||||||
@@ -2109,4 +2127,70 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Ошибка: $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 =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -429,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
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
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@@ -2064,6 +2082,72 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $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`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
|
|||||||
@@ -75,8 +75,10 @@
|
|||||||
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
|
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
|
||||||
"historyNoSingles": "No single downloads",
|
"historyNoSingles": "No single downloads",
|
||||||
"@historyNoSingles": {"description": "Empty state when filtering singles"},
|
"@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"},
|
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
|
||||||
|
"historySearchHint": "Search history...",
|
||||||
|
"@historySearchHint": {"description": "Search bar placeholder in history"},
|
||||||
|
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"@settingsTitle": {"description": "Settings screen title"},
|
"@settingsTitle": {"description": "Settings screen title"},
|
||||||
@@ -304,10 +306,20 @@
|
|||||||
"@aboutReportIssue": {"description": "Link to report bugs"},
|
"@aboutReportIssue": {"description": "Link to report bugs"},
|
||||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||||
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
|
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
|
||||||
"aboutFeatureRequest": "Feature request",
|
"aboutFeatureRequest": "Feature request",
|
||||||
"@aboutFeatureRequest": {"description": "Link to suggest features"},
|
"@aboutFeatureRequest": {"description": "Link to suggest features"},
|
||||||
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
||||||
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
|
"@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": "Support",
|
||||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||||
@@ -1537,5 +1549,80 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"message": {"type": "String", "description": "Error message"}
|
"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": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"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": {
|
"@historyTracksCount": {
|
||||||
"description": "Track count with plural form",
|
"description": "Track count with plural form",
|
||||||
"placeholders": {
|
"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": {
|
"@historyAlbumsCount": {
|
||||||
"description": "Album count with plural form",
|
"description": "Album count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -596,7 +596,7 @@
|
|||||||
"@albumTitle": {
|
"@albumTitle": {
|
||||||
"description": "Album screen title"
|
"description": "Album screen title"
|
||||||
},
|
},
|
||||||
"albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
"albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}",
|
||||||
"@albumTracks": {
|
"@albumTracks": {
|
||||||
"description": "Album track count",
|
"description": "Album track count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -633,7 +633,7 @@
|
|||||||
"@artistCompilations": {
|
"@artistCompilations": {
|
||||||
"description": "Section header for compilations"
|
"description": "Section header for compilations"
|
||||||
},
|
},
|
||||||
"artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}",
|
"artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}",
|
||||||
"@artistReleases": {
|
"@artistReleases": {
|
||||||
"description": "Artist release count",
|
"description": "Artist release count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1108,7 +1108,7 @@
|
|||||||
"@dialogDeleteSelectedTitle": {
|
"@dialogDeleteSelectedTitle": {
|
||||||
"description": "Dialog title - delete selected items"
|
"description": "Dialog title - delete selected items"
|
||||||
},
|
},
|
||||||
"dialogDeleteSelectedMessage": "¿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": {
|
"@dialogDeleteSelectedMessage": {
|
||||||
"description": "Dialog message - delete selected tracks",
|
"description": "Dialog message - delete selected tracks",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1169,7 +1169,7 @@
|
|||||||
"@snackbarCredentialsCleared": {
|
"@snackbarCredentialsCleared": {
|
||||||
"description": "Snackbar - Spotify credentials removed"
|
"description": "Snackbar - Spotify credentials removed"
|
||||||
},
|
},
|
||||||
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}",
|
"snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}",
|
||||||
"@snackbarDeletedTracks": {
|
"@snackbarDeletedTracks": {
|
||||||
"description": "Snackbar - tracks deleted",
|
"description": "Snackbar - tracks deleted",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1376,7 +1376,7 @@
|
|||||||
"@selectionTapToSelect": {
|
"@selectionTapToSelect": {
|
||||||
"description": "Hint - how to select items"
|
"description": "Hint - how to select items"
|
||||||
},
|
},
|
||||||
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
|
||||||
"@selectionDeleteTracks": {
|
"@selectionDeleteTracks": {
|
||||||
"description": "Delete button with count",
|
"description": "Delete button with count",
|
||||||
"placeholders": {
|
"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": {
|
"@tracksCount": {
|
||||||
"description": "Track count display",
|
"description": "Track count display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2520,7 +2520,7 @@
|
|||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"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": {
|
"@downloadedAlbumDeleteMessage": {
|
||||||
"description": "Delete confirmation with count",
|
"description": "Delete confirmation with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2559,7 +2559,7 @@
|
|||||||
"@downloadedAlbumTapToSelect": {
|
"@downloadedAlbumTapToSelect": {
|
||||||
"description": "Selection hint"
|
"description": "Selection hint"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
|
||||||
"@downloadedAlbumDeleteCount": {
|
"@downloadedAlbumDeleteCount": {
|
||||||
"description": "Delete button text with count",
|
"description": "Delete button text with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -683,5 +683,23 @@
|
|||||||
"recentTypePlaylist": "Playlist",
|
"recentTypePlaylist": "Playlist",
|
||||||
|
|
||||||
"recentPlaylistInfo": "Playlist: {name}",
|
"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": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"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": {
|
"@historyTracksCount": {
|
||||||
"description": "Track count with plural form",
|
"description": "Track count with plural form",
|
||||||
"placeholders": {
|
"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": {
|
"@historyAlbumsCount": {
|
||||||
"description": "Album count with plural form",
|
"description": "Album count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -596,7 +596,7 @@
|
|||||||
"@albumTitle": {
|
"@albumTitle": {
|
||||||
"description": "Album screen title"
|
"description": "Album screen title"
|
||||||
},
|
},
|
||||||
"albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
"albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}",
|
||||||
"@albumTracks": {
|
"@albumTracks": {
|
||||||
"description": "Album track count",
|
"description": "Album track count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -633,7 +633,7 @@
|
|||||||
"@artistCompilations": {
|
"@artistCompilations": {
|
||||||
"description": "Section header for compilations"
|
"description": "Section header for compilations"
|
||||||
},
|
},
|
||||||
"artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}",
|
"artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}",
|
||||||
"@artistReleases": {
|
"@artistReleases": {
|
||||||
"description": "Artist release count",
|
"description": "Artist release count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1376,7 +1376,7 @@
|
|||||||
"@selectionTapToSelect": {
|
"@selectionTapToSelect": {
|
||||||
"description": "Hint - how to select items"
|
"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": {
|
"@selectionDeleteTracks": {
|
||||||
"description": "Delete button with count",
|
"description": "Delete button with count",
|
||||||
"placeholders": {
|
"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": {
|
"@tracksCount": {
|
||||||
"description": "Track count display",
|
"description": "Track count display",
|
||||||
"placeholders": {
|
"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/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
import 'package:spotiflac_android/services/notification_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';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('DownloadQueue');
|
final _log = AppLogger('DownloadQueue');
|
||||||
@@ -130,15 +131,36 @@ class DownloadHistoryItem {
|
|||||||
class DownloadHistoryState {
|
class DownloadHistoryState {
|
||||||
final List<DownloadHistoryItem> items;
|
final List<DownloadHistoryItem> items;
|
||||||
final Set<String> _downloadedSpotifyIds;
|
final Set<String> _downloadedSpotifyIds;
|
||||||
|
final Map<String, DownloadHistoryItem> _bySpotifyId;
|
||||||
|
final Map<String, DownloadHistoryItem> _byIsrc;
|
||||||
|
|
||||||
DownloadHistoryState({this.items = const []})
|
DownloadHistoryState({this.items = const []})
|
||||||
: _downloadedSpotifyIds = items
|
: _downloadedSpotifyIds = items
|
||||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||||
.map((item) => item.spotifyId!)
|
.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) =>
|
bool isDownloaded(String spotifyId) =>
|
||||||
_downloadedSpotifyIds.contains(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}) {
|
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||||
return DownloadHistoryState(items: items ?? this.items);
|
return DownloadHistoryState(items: items ?? this.items);
|
||||||
@@ -146,130 +168,58 @@ class DownloadHistoryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||||
static const _storageKey = 'download_history';
|
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadHistoryState build() {
|
DownloadHistoryState build() {
|
||||||
_loadFromStorageSync();
|
_loadFromDatabaseSync();
|
||||||
return DownloadHistoryState();
|
return DownloadHistoryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||||
void _loadFromStorageSync() {
|
void _loadFromDatabaseSync() {
|
||||||
if (_isLoaded) return;
|
if (_isLoaded) return;
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await _loadFromStorage();
|
await _loadFromDatabase();
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadFromStorage() async {
|
Future<void> _loadFromDatabase() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await _prefs;
|
final migrated = await _db.migrateFromSharedPreferences();
|
||||||
final jsonStr = prefs.getString(_storageKey);
|
if (migrated) {
|
||||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
_historyLog.i('Migrated history from SharedPreferences to SQLite');
|
||||||
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}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key != null) {
|
final jsonList = await _db.getAll();
|
||||||
if (!seen.containsKey(key)) {
|
final items = jsonList
|
||||||
seen[key] = result.length;
|
.map((e) => DownloadHistoryItem.fromJson(e))
|
||||||
result.add(item);
|
.toList();
|
||||||
} else {
|
|
||||||
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
state = state.copyWith(items: items);
|
||||||
}
|
_historyLog.i('Loaded ${items.length} items from SQLite database');
|
||||||
} else {
|
} catch (e, stack) {
|
||||||
result.add(item);
|
_historyLog.e('Failed to load history from database: $e', e, stack);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reloadFromStorage() async {
|
Future<void> reloadFromStorage() async {
|
||||||
await _loadFromStorage();
|
await _loadFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
void addToHistory(DownloadHistoryItem item) {
|
void addToHistory(DownloadHistoryItem item) {
|
||||||
final existingIndex = state.items.indexWhere((existing) {
|
DownloadHistoryItem? existing;
|
||||||
if (item.spotifyId != null &&
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||||
item.spotifyId!.isNotEmpty &&
|
existing = state.getBySpotifyId(item.spotifyId!);
|
||||||
existing.spotifyId == item.spotifyId) {
|
}
|
||||||
return true;
|
if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) {
|
||||||
}
|
existing = state.getByIsrc(item.isrc!);
|
||||||
|
}
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existing != null) {
|
||||||
final updatedItems = [...state.items];
|
final updatedItems = state.items.where((i) => i.id != existing!.id).toList();
|
||||||
updatedItems[existingIndex] = item;
|
|
||||||
updatedItems.removeAt(existingIndex);
|
|
||||||
updatedItems.insert(0, item);
|
updatedItems.insert(0, item);
|
||||||
state = state.copyWith(items: updatedItems);
|
state = state.copyWith(items: updatedItems);
|
||||||
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||||
@@ -277,31 +227,60 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
state = state.copyWith(items: [item, ...state.items]);
|
state = state.copyWith(items: [item, ...state.items]);
|
||||||
_historyLog.d('Added new history entry: ${item.trackName}');
|
_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) {
|
void removeFromHistory(String id) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: state.items.where((item) => item.id != id).toList(),
|
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) {
|
void removeBySpotifyId(String spotifyId) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
|
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');
|
_historyLog.d('Removed item with spotifyId: $spotifyId');
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadHistoryItem? getBySpotifyId(String 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() {
|
void clearHistory() {
|
||||||
state = DownloadHistoryState();
|
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) {
|
String _sanitizeFolderName(String name) {
|
||||||
return name
|
return name
|
||||||
.replaceAll(_invalidFolderChars, '_')
|
.replaceAll(_invalidFolderChars, '_')
|
||||||
.replaceAll(_trailingDotsRegex, '') // Remove trailing dots
|
.replaceAll(_trailingDotsRegex, '')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,8 +1046,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
/// Same logic as Go backend cover.go
|
/// Same logic as Go backend cover.go
|
||||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
const spotifySize300 = 'ab67616d00001e02';
|
||||||
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
const spotifySize640 = 'ab67616d0000b273';
|
||||||
const spotifySizeMax = 'ab67616d000082c1';
|
const spotifySizeMax = 'ab67616d000082c1';
|
||||||
|
|
||||||
var result = coverUrl;
|
var result = coverUrl;
|
||||||
@@ -1655,7 +1634,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
|
|
||||||
// Fetch extended metadata (genre, label) from Deezer if available
|
// Fetch extended metadata (genre, label) from Deezer if available
|
||||||
String? genre;
|
String? genre;
|
||||||
String? label;
|
String? label;
|
||||||
|
|
||||||
@@ -1667,6 +1646,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
deezerTrackId = trackToDownload.availability!.deezerId;
|
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) {
|
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId);
|
final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId);
|
||||||
@@ -1758,9 +1750,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id,
|
||||||
durationMs:
|
durationMs: trackToDownload.duration,
|
||||||
trackToDownload.duration, // Duration in ms for verification
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1800,7 +1791,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||||
final actualSampleRate = result['actual_sample_rate'] 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) {
|
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
// 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 URLHandler? urlHandler;
|
||||||
final TrackMatching? trackMatching;
|
final TrackMatching? trackMatching;
|
||||||
final PostProcessing? postProcessing;
|
final PostProcessing? postProcessing;
|
||||||
|
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||||
|
|
||||||
const Extension({
|
const Extension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -48,6 +49,7 @@ class Extension {
|
|||||||
this.urlHandler,
|
this.urlHandler,
|
||||||
this.trackMatching,
|
this.trackMatching,
|
||||||
this.postProcessing,
|
this.postProcessing,
|
||||||
|
this.capabilities = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Extension.fromJson(Map<String, dynamic> json) {
|
factory Extension.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -84,6 +86,7 @@ class Extension {
|
|||||||
postProcessing: json['post_processing'] != null
|
postProcessing: json['post_processing'] != null
|
||||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ class Extension {
|
|||||||
URLHandler? urlHandler,
|
URLHandler? urlHandler,
|
||||||
TrackMatching? trackMatching,
|
TrackMatching? trackMatching,
|
||||||
PostProcessing? postProcessing,
|
PostProcessing? postProcessing,
|
||||||
|
Map<String, dynamic>? capabilities,
|
||||||
}) {
|
}) {
|
||||||
return Extension(
|
return Extension(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -130,6 +134,7 @@ class Extension {
|
|||||||
urlHandler: urlHandler ?? this.urlHandler,
|
urlHandler: urlHandler ?? this.urlHandler,
|
||||||
trackMatching: trackMatching ?? this.trackMatching,
|
trackMatching: trackMatching ?? this.trackMatching,
|
||||||
postProcessing: postProcessing ?? this.postProcessing,
|
postProcessing: postProcessing ?? this.postProcessing,
|
||||||
|
capabilities: capabilities ?? this.capabilities,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +142,8 @@ class Extension {
|
|||||||
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||||
|
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||||
|
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SearchBehavior {
|
class SearchBehavior {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:spotiflac_android/services/palette_service.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
@@ -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/providers/recent_access_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.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 {
|
class _AlbumCache {
|
||||||
static final Map<String, _CacheEntry> _cache = {};
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
@@ -43,6 +45,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
|||||||
final String albumName;
|
final String albumName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<Track>? tracks; // Optional - will fetch if null
|
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({
|
const AlbumScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -50,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
|||||||
required this.albumName,
|
required this.albumName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
this.tracks,
|
this.tracks,
|
||||||
|
this.extensionId,
|
||||||
|
this.artistId,
|
||||||
|
this.artistName,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,6 +70,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
Color? _dominantColor;
|
Color? _dominantColor;
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
|
String? _artistId;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,10 +87,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||||
imageUrl: widget.coverUrl,
|
imageUrl: widget.coverUrl,
|
||||||
providerId: providerId,
|
providerId: providerId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
|
_artistId = widget.artistId; // Use provided artist ID if available
|
||||||
|
|
||||||
if (_tracks == null) {
|
if (_tracks == null) {
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
}
|
}
|
||||||
@@ -103,25 +114,33 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractDominantColor() async {
|
Future<void> _extractDominantColor() async {
|
||||||
if (widget.coverUrl == null) return;
|
if (widget.coverUrl == null) return;
|
||||||
try {
|
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
if (mounted && color != null) {
|
||||||
CachedNetworkImageProvider(widget.coverUrl!),
|
setState(() => _dominantColor = color);
|
||||||
maximumColorCount: 16,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_dominantColor = paletteGenerator.dominantColor?.color ??
|
|
||||||
paletteGenerator.vibrantColor?.color ??
|
|
||||||
paletteGenerator.mutedColor?.color;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
@@ -137,11 +156,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
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);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
_isLoading = false;
|
_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 tracks = _tracks ?? [];
|
||||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||||
|
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -332,32 +357,59 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
),
|
),
|
||||||
if (artistName != null && artistName.isNotEmpty) ...[
|
if (artistName != null && artistName.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
GestureDetector(
|
||||||
artistName,
|
onTap: () => _navigateToArtist(context, artistName),
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
child: Text(
|
||||||
|
artistName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (tracks.isNotEmpty)
|
if (tracks.isNotEmpty)
|
||||||
Container(
|
Wrap(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
spacing: 8,
|
||||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
runSpacing: 8,
|
||||||
child: Row(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Container(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
const SizedBox(width: 4),
|
child: Row(
|
||||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
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) ...[
|
if (tracks.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _downloadAll(context),
|
onPressed: () => _downloadAll(context),
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download, size: 18),
|
||||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
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) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
final isRateLimit = error.contains('429') ||
|
final isRateLimit = error.contains('429') ||
|
||||||
error.toLowerCase().contains('rate limit') ||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
@@ -534,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
leading: track.coverUrl != null
|
leading: SizedBox(
|
||||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
|
width: 32,
|
||||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
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)),
|
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)),
|
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),
|
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 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:intl/intl.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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
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
|
/// Simple in-memory cache for artist data
|
||||||
class _ArtistCache {
|
class _ArtistCache {
|
||||||
@@ -100,6 +102,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
// Selection mode state
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
final Set<String> _selectedAlbumIds = {};
|
||||||
|
bool _isFetchingDiscography = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -278,11 +285,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
return Scaffold(
|
final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty;
|
||||||
body: CustomScrollView(
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isSelectionMode,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop && _isSelectionMode) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildHeader(context, colorScheme),
|
_buildHeader(context, colorScheme, albums: albums, hasDiscography: hasDiscography),
|
||||||
if (_isLoadingDiscography)
|
if (_isLoadingDiscography)
|
||||||
const SliverToBoxAdapter(child: Padding(
|
const SliverToBoxAdapter(child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(32),
|
||||||
@@ -303,13 +321,444 @@ return Scaffold(
|
|||||||
if (compilations.isNotEmpty)
|
if (compilations.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
|
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;
|
String? imageUrl = _headerImageUrl;
|
||||||
if (imageUrl == null || imageUrl.isEmpty) {
|
if (imageUrl == null || imageUrl.isEmpty) {
|
||||||
imageUrl = widget.headerImageUrl;
|
imageUrl = widget.headerImageUrl;
|
||||||
@@ -330,7 +779,7 @@ return Scaffold(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 380,
|
expandedHeight: hasDiscography ? 420 : 380,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
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) {
|
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||||
|
final isSelected = _selectedAlbumIds.contains(album.id);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToAlbum(album),
|
onTap: () {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
_toggleAlbumSelection(album.id);
|
||||||
|
} else {
|
||||||
|
_navigateToAlbum(album);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
if (!_isSelectionMode) {
|
||||||
|
_enterSelectionMode(album.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 140,
|
width: 140,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: album.coverUrl != null
|
child: album.coverUrl != null
|
||||||
@@ -775,6 +1259,50 @@ if (hasValidImage)
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
|
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),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.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/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
@@ -59,36 +59,36 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
Future<void> _extractDominantColor() async {
|
Future<void> _extractDominantColor() async {
|
||||||
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
|
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
|
||||||
|
|
||||||
// Only use network images for palette extraction
|
// Check cache first (instant)
|
||||||
final isNetworkUrl = widget.coverUrl!.startsWith('http://') ||
|
final cached = PaletteService.instance.getCached(widget.coverUrl);
|
||||||
widget.coverUrl!.startsWith('https://');
|
if (cached != null) {
|
||||||
if (!isNetworkUrl) return;
|
if (mounted && cached != _dominantColor) {
|
||||||
|
|
||||||
try {
|
|
||||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
|
||||||
CachedNetworkImageProvider(widget.coverUrl!),
|
|
||||||
maximumColorCount: 16,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_dominantColor = paletteGenerator.dominantColor?.color ??
|
_dominantColor = cached;
|
||||||
paletteGenerator.vibrantColor?.color ??
|
|
||||||
paletteGenerator.mutedColor?.color;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} 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)
|
/// Get tracks for this album from history provider (reactive)
|
||||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||||
return allItems.where((item) {
|
return allItems.where((item) {
|
||||||
// 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)
|
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
? item.albumArtist!
|
? item.albumArtist!
|
||||||
: item.artistName;
|
: item.artistName;
|
||||||
final itemKey = '${item.albumName}|$itemArtist';
|
// Use lowercase for case-insensitive matching
|
||||||
final albumKey = '${widget.albumName}|${widget.artistName}';
|
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||||
|
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||||
return itemKey == albumKey;
|
return itemKey == albumKey;
|
||||||
}).toList()
|
}).toList()
|
||||||
..sort((a, b) {
|
..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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/recent_access_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/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
@@ -33,6 +34,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
bool _isTyping = false;
|
bool _isTyping = false;
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
String? _lastSearchQuery;
|
String? _lastSearchQuery;
|
||||||
|
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||||
|
late final ProviderSubscription<bool> _extensionInitSub;
|
||||||
|
|
||||||
/// Debounce timer for live search (extension-only feature)
|
/// Debounce timer for live search (extension-only feature)
|
||||||
Timer? _liveSearchDebounce;
|
Timer? _liveSearchDebounce;
|
||||||
@@ -57,11 +60,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
super.initState();
|
super.initState();
|
||||||
_urlController.addListener(_onSearchChanged);
|
_urlController.addListener(_onSearchChanged);
|
||||||
_searchFocusNode.addListener(_onSearchFocusChanged);
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_liveSearchDebounce?.cancel();
|
_liveSearchDebounce?.cancel();
|
||||||
|
_trackStateSub.close();
|
||||||
|
_extensionInitSub.close();
|
||||||
_urlController.removeListener(_onSearchChanged);
|
_urlController.removeListener(_onSearchChanged);
|
||||||
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
||||||
_urlController.dispose();
|
_urlController.dispose();
|
||||||
@@ -109,14 +143,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
} else if (text.isEmpty && _isTyping) {
|
} else if (text.isEmpty && _isTyping) {
|
||||||
setState(() => _isTyping = false);
|
setState(() => _isTyping = false);
|
||||||
_liveSearchDebounce?.cancel();
|
_liveSearchDebounce?.cancel();
|
||||||
// Don't clear provider here - it causes focus issues
|
|
||||||
// Provider will be cleared when user explicitly clears or navigates away
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live search - only for extensions
|
|
||||||
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
|
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;
|
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||||
|
|
||||||
_liveSearchDebounce?.cancel();
|
_liveSearchDebounce?.cancel();
|
||||||
@@ -142,7 +172,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
} finally {
|
} finally {
|
||||||
_isLiveSearchInProgress = false;
|
_isLiveSearchInProgress = false;
|
||||||
|
|
||||||
// Check if there's a pending query that was queued while we were searching
|
|
||||||
final pending = _pendingLiveSearchQuery;
|
final pending = _pendingLiveSearchQuery;
|
||||||
_pendingLiveSearchQuery = null;
|
_pendingLiveSearchQuery = null;
|
||||||
|
|
||||||
@@ -372,7 +401,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Use default settings without quality picker
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: this.context,
|
context: this.context,
|
||||||
builder: (dialogCtx) => AlertDialog(
|
builder: (dialogCtx) => AlertDialog(
|
||||||
@@ -413,34 +441,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(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 tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||||
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
||||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||||
|
|
||||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
final exploreState = ref.watch(exploreProvider);
|
||||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) =>
|
||||||
|
s.extensions.any((e) => e.enabled && e.hasHomeFeed)
|
||||||
|
));
|
||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
|
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
|
||||||
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
||||||
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final screenHeight = mediaQuery.size.height;
|
||||||
|
final topPadding = mediaQuery.padding.top;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
|
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
|
||||||
|
|
||||||
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
|
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
|
||||||
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
|
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
|
||||||
|
|
||||||
|
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent;
|
||||||
|
|
||||||
if (hasActualResults && isShowingRecentAccess) {
|
if (hasActualResults && isShowingRecentAccess) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||||
@@ -455,9 +481,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
},
|
},
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: RefreshIndicator(
|
||||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
onRefresh: () => ref.read(exploreProvider.notifier).refresh(),
|
||||||
slivers: [
|
notificationPredicate: (notification) => showExplore,
|
||||||
|
child: CustomScrollView(
|
||||||
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -492,7 +521,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: hasResults
|
child: (hasResults || showExplore)
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -541,7 +570,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
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),
|
child: _buildSearchBar(colorScheme),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -559,7 +588,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: (hasResults || showRecentAccess)
|
child: (hasResults || showRecentAccess || showExplore)
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
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(
|
..._buildSearchResults(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: searchArtists,
|
searchArtists: searchArtists,
|
||||||
@@ -594,6 +634,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
), // Close RefreshIndicator
|
||||||
), // Close GestureDetector
|
), // 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(
|
Widget _buildRecentAccess(
|
||||||
List<RecentAccessItem> items,
|
List<RecentAccessItem> items,
|
||||||
List<DownloadHistoryItem> historyItems,
|
List<DownloadHistoryItem> historyItems,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
// Group download history by album
|
|
||||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
||||||
for (final h in historyItems) {
|
for (final h in historyItems) {
|
||||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||||
@@ -736,7 +1162,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
return true;
|
return true;
|
||||||
}).take(10).toList();
|
}).take(10).toList();
|
||||||
|
|
||||||
// Check if there are hidden downloads
|
|
||||||
final hasHiddenDownloads = hiddenIds.isNotEmpty;
|
final hasHiddenDownloads = hiddenIds.isNotEmpty;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -805,6 +1230,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) {
|
Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) {
|
||||||
IconData typeIcon;
|
IconData typeIcon;
|
||||||
String typeLabel;
|
String typeLabel;
|
||||||
|
final isDownloaded = item.providerId == 'download';
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case RecentAccessType.artist:
|
case RecentAccessType.artist:
|
||||||
typeIcon = Icons.person;
|
typeIcon = Icons.person;
|
||||||
@@ -868,11 +1295,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
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,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
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;
|
currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine display icon
|
|
||||||
IconData displayIcon = Icons.search;
|
IconData displayIcon = Icons.search;
|
||||||
String? iconPath;
|
String? iconPath;
|
||||||
if (currentExt != null) {
|
if (currentExt != null) {
|
||||||
@@ -1612,7 +2040,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||||
// Extension providers
|
|
||||||
...searchProviders.map((ext) => PopupMenuItem<String>(
|
...searchProviders.map((ext) => PopupMenuItem<String>(
|
||||||
value: ext.id,
|
value: ext.id,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -2018,6 +2445,8 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
List<Track>? _tracks;
|
List<Track>? _tracks;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
String? _artistId;
|
||||||
|
String? _artistName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -2057,8 +2486,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
|
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
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(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
|
_artistName = artistName;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2127,6 +2562,9 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
albumName: widget.albumName,
|
albumName: widget.albumName,
|
||||||
coverUrl: widget.coverUrl,
|
coverUrl: widget.coverUrl,
|
||||||
tracks: _tracks,
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:spotiflac_android/services/palette_service.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
@@ -55,19 +55,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
|
|
||||||
Future<void> _extractDominantColor() async {
|
Future<void> _extractDominantColor() async {
|
||||||
if (widget.coverUrl == null) return;
|
if (widget.coverUrl == null) return;
|
||||||
try {
|
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
if (mounted && color != null) {
|
||||||
CachedNetworkImageProvider(widget.coverUrl!),
|
setState(() => _dominantColor = color);
|
||||||
maximumColorCount: 16,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_dominantColor = paletteGenerator.dominantColor?.color ??
|
|
||||||
paletteGenerator.vibrantColor?.color ??
|
|
||||||
paletteGenerator.mutedColor?.color;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,12 +215,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _downloadAll(context),
|
onPressed: () => _downloadAll(context),
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download, size: 18),
|
||||||
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
|
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
|
||||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -19,6 +21,7 @@ class _GroupedAlbum {
|
|||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<DownloadHistoryItem> tracks;
|
final List<DownloadHistoryItem> tracks;
|
||||||
final DateTime latestDownload;
|
final DateTime latestDownload;
|
||||||
|
final String searchKey;
|
||||||
|
|
||||||
_GroupedAlbum({
|
_GroupedAlbum({
|
||||||
required this.albumName,
|
required this.albumName,
|
||||||
@@ -26,7 +29,7 @@ class _GroupedAlbum {
|
|||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.tracks,
|
required this.tracks,
|
||||||
required this.latestDownload,
|
required this.latestDownload,
|
||||||
});
|
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
|
|
||||||
String get key => '$albumName|$artistName';
|
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 {
|
class QueueTab extends ConsumerStatefulWidget {
|
||||||
final PageController? parentPageController;
|
final PageController? parentPageController;
|
||||||
final int parentPageIndex;
|
final int parentPageIndex;
|
||||||
@@ -73,6 +112,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
||||||
bool _isPageControllerInitialized = false;
|
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
|
@override
|
||||||
@@ -88,12 +145,178 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_filterPageController = PageController(initialPage: initialPage);
|
_filterPageController = PageController(initialPage: initialPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_filterPageController?.dispose();
|
_filterPageController?.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
_searchFocusNode.dispose();
|
||||||
|
_searchDebounce?.cancel();
|
||||||
super.dispose();
|
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) {
|
void _onFilterPageChanged(int index) {
|
||||||
final filterMode = _filterModes[index];
|
final filterMode = _filterModes[index];
|
||||||
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
@@ -285,11 +509,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
);
|
).then((_) => _searchFocusNode.unfocus());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
|
_searchFocusNode.unfocus();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
@@ -300,46 +525,63 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
);
|
).then((_) => _searchFocusNode.unfocus());
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DownloadHistoryItem> _filterHistoryItems(
|
List<DownloadHistoryItem> _filterHistoryItems(
|
||||||
List<DownloadHistoryItem> items,
|
List<DownloadHistoryItem> items,
|
||||||
String filterMode,
|
String filterMode,
|
||||||
Map<String, int> albumCounts,
|
Map<String, int> albumCounts, [
|
||||||
) {
|
String searchQuery = '',
|
||||||
if (filterMode == 'all') return items;
|
]) {
|
||||||
|
// 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':
|
case 'albums':
|
||||||
return items.where((item) {
|
return filteredItems.where((item) {
|
||||||
final key =
|
final key =
|
||||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||||
return (albumCounts[key] ?? 0) > 1;
|
return (albumCounts[key] ?? 0) > 1;
|
||||||
}).toList();
|
}).toList();
|
||||||
case 'singles':
|
case 'singles':
|
||||||
return items.where((item) {
|
return filteredItems.where((item) {
|
||||||
final key =
|
final key =
|
||||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||||
return (albumCounts[key] ?? 0) == 1;
|
return (albumCounts[key] ?? 0) == 1;
|
||||||
}).toList();
|
}).toList();
|
||||||
default:
|
default:
|
||||||
return items;
|
return filteredItems;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
|
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
|
||||||
final albumCounts = <String, int>{};
|
final albumCounts = <String, int>{};
|
||||||
final albumMap = <String, List<DownloadHistoryItem>>{};
|
final albumMap = <String, List<DownloadHistoryItem>>{};
|
||||||
for (final item in items) {
|
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;
|
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
|
||||||
albumMap.putIfAbsent(key, () => []).add(item);
|
albumMap.putIfAbsent(key, () => []).add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
int singleTracks = 0;
|
int singleTracks = 0;
|
||||||
for (final item in items) {
|
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) {
|
if ((albumCounts[key] ?? 0) <= 1) {
|
||||||
singleTracks++;
|
singleTracks++;
|
||||||
}
|
}
|
||||||
@@ -380,7 +622,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
||||||
|
_searchFocusNode.unfocus();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
@@ -395,27 +638,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
);
|
).then((_) => _searchFocusNode.unfocus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_initializePageController();
|
_initializePageController();
|
||||||
|
|
||||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
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 allHistoryItems = ref.watch(
|
final allHistoryItems = ref.watch(
|
||||||
downloadHistoryProvider.select((s) => s.items),
|
downloadHistoryProvider.select((s) => s.items),
|
||||||
);
|
);
|
||||||
|
_ensureHistoryCaches(allHistoryItems);
|
||||||
final historyViewMode = ref.watch(
|
final historyViewMode = ref.watch(
|
||||||
settingsProvider.select((s) => s.historyViewMode),
|
settingsProvider.select((s) => s.historyViewMode),
|
||||||
);
|
);
|
||||||
@@ -425,7 +659,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
final historyStats = _buildHistoryStats(allHistoryItems);
|
final historyStats =
|
||||||
|
_historyStatsCache ?? _buildHistoryStats(allHistoryItems);
|
||||||
final groupedAlbums = historyStats.groupedAlbums;
|
final groupedAlbums = historyStats.groupedAlbums;
|
||||||
final albumCount = historyStats.albumCount;
|
final albumCount = historyStats.albumCount;
|
||||||
final singleCount = historyStats.singleTracks;
|
final singleCount = historyStats.singleTracks;
|
||||||
@@ -480,68 +715,82 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if ((isProcessing || queuedCount > 0) &&
|
// Search bar - always at top
|
||||||
(queueItems.length > 1 || isPaused))
|
if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
child: Card(
|
child: GestureDetector(
|
||||||
child: Padding(
|
onTap: () {},
|
||||||
padding: const EdgeInsets.all(12),
|
child: TextField(
|
||||||
child: Row(
|
controller: _searchController,
|
||||||
children: [
|
focusNode: _searchFocusNode,
|
||||||
Container(
|
autofocus: false,
|
||||||
padding: const EdgeInsets.all(8),
|
canRequestFocus: true,
|
||||||
decoration: BoxDecoration(
|
decoration: InputDecoration(
|
||||||
color: isPaused
|
hintText: context.l10n.historySearchHint,
|
||||||
? colorScheme.errorContainer
|
prefixIcon: const Icon(Icons.search),
|
||||||
: colorScheme.primaryContainer,
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
borderRadius: BorderRadius.circular(12),
|
? IconButton(
|
||||||
),
|
icon: const Icon(Icons.clear),
|
||||||
child: Icon(
|
onPressed: () {
|
||||||
isPaused ? Icons.pause : Icons.downloading,
|
_searchController.clear();
|
||||||
color: isPaused
|
_clearSearch();
|
||||||
? colorScheme.onErrorContainer
|
FocusScope.of(context).unfocus();
|
||||||
: colorScheme.onPrimaryContainer,
|
},
|
||||||
),
|
)
|
||||||
|
: null,
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
width: 1,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Expanded(
|
enabledBorder: OutlineInputBorder(
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(28),
|
||||||
isPaused
|
borderSide: BorderSide(
|
||||||
? 'Paused'
|
color: colorScheme.outlineVariant,
|
||||||
: '$completedCount/${queueItems.length}',
|
width: 1.5,
|
||||||
style: Theme.of(context).textTheme.titleSmall
|
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
FilledButton.tonal(
|
),
|
||||||
onPressed: () => ref
|
focusedBorder: OutlineInputBorder(
|
||||||
.read(downloadQueueProvider.notifier)
|
borderRadius: BorderRadius.circular(28),
|
||||||
.togglePause(),
|
borderSide: BorderSide(
|
||||||
child: Text(isPaused ? 'Resume' : 'Pause'),
|
color: colorScheme.primary,
|
||||||
|
width: 2.5,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
onChanged: _onSearchChanged,
|
||||||
|
onTapOutside: (_) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
if (queueItems.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Downloading (${queueItems.length})',
|
'Downloading (${queueItems.length})',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
if (queueItems.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverList(
|
SliverList(
|
||||||
@@ -551,7 +800,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
child: _buildQueueItem(context, item, colorScheme),
|
child: _buildQueueItem(context, item, colorScheme),
|
||||||
);
|
);
|
||||||
}, childCount: queueItems.length),
|
}, childCount: queueItems.length),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (allHistoryItems.isNotEmpty)
|
if (allHistoryItems.isNotEmpty)
|
||||||
@@ -655,42 +904,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: PageView(
|
child: PageView.builder(
|
||||||
controller: _filterPageController!,
|
controller: _filterPageController!,
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
onPageChanged: _onFilterPageChanged,
|
onPageChanged: _onFilterPageChanged,
|
||||||
children: [
|
itemCount: _filterModes.length,
|
||||||
_buildFilterContent(
|
itemBuilder: (context, index) {
|
||||||
|
final filterMode = _filterModes[index];
|
||||||
|
return _buildFilterContent(
|
||||||
context: context,
|
context: context,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
filterMode: 'all',
|
filterMode: filterMode,
|
||||||
allHistoryItems: allHistoryItems,
|
allHistoryItems: allHistoryItems,
|
||||||
historyViewMode: historyViewMode,
|
historyViewMode: historyViewMode,
|
||||||
queueItems: queueItems,
|
queueItems: queueItems,
|
||||||
groupedAlbums: groupedAlbums,
|
groupedAlbums: groupedAlbums,
|
||||||
albumCounts: historyStats.albumCounts,
|
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,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||||
child: _buildSelectionBottomBar(
|
child: _buildSelectionBottomBar(
|
||||||
context,
|
context,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
_filterHistoryItems(
|
_resolveHistoryItems(
|
||||||
allHistoryItems,
|
filterMode: historyFilterMode,
|
||||||
historyFilterMode,
|
allHistoryItems: allHistoryItems,
|
||||||
historyStats.albumCounts,
|
albumCounts: historyStats.albumCounts,
|
||||||
),
|
),
|
||||||
bottomPadding,
|
bottomPadding,
|
||||||
),
|
),
|
||||||
@@ -726,10 +957,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
required String historyViewMode,
|
required String historyViewMode,
|
||||||
required List<DownloadItem> queueItems,
|
required List<DownloadItem> queueItems,
|
||||||
required List<_GroupedAlbum> groupedAlbums,
|
required List<_GroupedAlbum> groupedAlbums,
|
||||||
required Map<String, int> albumCounts,
|
required Map<String, int> albumCounts,
|
||||||
}) {
|
}) {
|
||||||
final historyItems =
|
final historyItems = _resolveHistoryItems(
|
||||||
_filterHistoryItems(allHistoryItems, filterMode, albumCounts);
|
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(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -763,14 +1009,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (groupedAlbums.isNotEmpty &&
|
if (filteredGroupedAlbums.isNotEmpty &&
|
||||||
queueItems.isEmpty &&
|
queueItems.isEmpty &&
|
||||||
filterMode == 'albums')
|
filterMode == 'albums')
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}',
|
'${filteredGroupedAlbums.length} ${filteredGroupedAlbums.length == 1 ? 'album' : 'albums'}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
@@ -803,12 +1075,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
childAspectRatio: 0.75,
|
childAspectRatio: 0.75,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final album = groupedAlbums[index];
|
final album = filteredGroupedAlbums[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(album.key),
|
key: ValueKey(album.key),
|
||||||
child: _buildAlbumGridItem(context, album, colorScheme),
|
child: _buildAlbumGridItem(context, album, colorScheme),
|
||||||
);
|
);
|
||||||
}, childCount: groupedAlbums.length),
|
}, childCount: filteredGroupedAlbums.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -854,9 +1126,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}, childCount: historyItems.length ),
|
}, childCount: historyItems.length ),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (queueItems.isEmpty &&
|
if (queueItems.isEmpty &&
|
||||||
historyItems.isEmpty &&
|
historyItems.isEmpty &&
|
||||||
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
(filterMode != 'albums' || filteredGroupedAlbums.isEmpty) &&
|
||||||
|
!showFilteringIndicator)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: _buildEmptyState(
|
child: _buildEmptyState(
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ class AboutPage extends StatelessWidget {
|
|||||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_AboutSettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.lightbulb_outline,
|
icon: Icons.lightbulb_outline,
|
||||||
title: context.l10n.aboutFeatureRequest,
|
title: context.l10n.aboutFeatureRequest,
|
||||||
subtitle: context.l10n.aboutFeatureRequestSubtitle,
|
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(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -709,6 +709,7 @@ static const _allLanguages = [
|
|||||||
('pt', 'Português', Icons.language),
|
('pt', 'Português', Icons.language),
|
||||||
('pt_PT', 'Português (Brasil)', Icons.language),
|
('pt_PT', 'Português (Brasil)', Icons.language),
|
||||||
('ru', 'Русский', Icons.language),
|
('ru', 'Русский', Icons.language),
|
||||||
|
('tr', 'Türkçe', Icons.language),
|
||||||
('zh', '简体中文', Icons.language),
|
('zh', '简体中文', Icons.language),
|
||||||
('zh_CN', '简体中文 (中国)', Icons.language),
|
('zh_CN', '简体中文 (中国)', Icons.language),
|
||||||
('zh_TW', '繁體中文', Icons.language),
|
('zh_TW', '繁體中文', Icons.language),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.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:spotiflac_android/utils/mime_utils.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -61,7 +61,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
_checkFile();
|
_checkFile();
|
||||||
_extractDominantColor();
|
// Delay palette extraction to avoid jitter during initial build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_extractDominantColor();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -80,25 +83,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
Future<void> _extractDominantColor() async {
|
Future<void> _extractDominantColor() async {
|
||||||
final coverUrl = widget.item.coverUrl;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
// Extract using PaletteService (runs in isolate)
|
||||||
CachedNetworkImageProvider(coverUrl),
|
final color = await PaletteService.instance.extractDominantColor(coverUrl);
|
||||||
size: const Size(128, 128),
|
if (mounted && color != null && color != _dominantColor) {
|
||||||
maximumColorCount: 12,
|
setState(() => _dominantColor = color);
|
||||||
);
|
|
||||||
final nextColor = paletteGenerator.dominantColor?.color ??
|
|
||||||
paletteGenerator.vibrantColor?.color ??
|
|
||||||
paletteGenerator.mutedColor?.color;
|
|
||||||
if (mounted && nextColor != _dominantColor) {
|
|
||||||
setState(() {
|
|
||||||
_dominantColor = nextColor;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
static Future<Map<String, dynamic>> runPostProcessing(
|
||||||
String filePath, {
|
String filePath, {
|
||||||
|
|||||||
@@ -1027,7 +1027,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.1"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqflite
|
name: sqflite
|
||||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.1.3+62
|
version: 3.2.0+63
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -26,6 +26,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
sqflite: ^2.4.1
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.1.3+62
|
version: 3.2.0+63
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -26,6 +26,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
sqflite: ^2.4.1
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
|
|||||||