Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6388f3a5b8 | |||
| 55b75dc48d | |||
| f6cea1a683 | |||
| 8d205600b8 | |||
| aa35f60fad | |||
| b627ae1874 | |||
| 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,133 @@ 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)
|
||||||
|
# Use tr -d '\r' to handle CRLF line endings from Windows
|
||||||
|
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
||||||
|
|
||||||
|
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
||||||
|
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
||||||
|
|
||||||
|
if [ -z "$FULL_CHANGELOG" ]; then
|
||||||
|
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
|
||||||
|
# - Remove > blockquote prefix
|
||||||
|
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||||
|
sed 's/^> //' | \
|
||||||
|
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
|
||||||
|
echo "DEBUG: Final changelog:"
|
||||||
|
cat /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
|
||||||
@@ -339,10 +340,16 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
albumType = "compilation"
|
albumType = "compilation"
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, track := range album.Tracks.Data {
|
for i, track := range album.Tracks.Data {
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
isrc := isrcMap[trackIDStr]
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
// Use track position from API, fallback to index+1 if not provided
|
||||||
|
trackNum := track.TrackPosition
|
||||||
|
if trackNum == 0 {
|
||||||
|
trackNum = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
Artists: track.Artist.Name,
|
Artists: track.Artist.Name,
|
||||||
@@ -352,7 +359,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: trackNum,
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
|
|||||||
@@ -615,10 +615,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"source": lyrics.Source,
|
"source": lyrics.Source,
|
||||||
"sync_type": lyrics.SyncType,
|
"sync_type": lyrics.SyncType,
|
||||||
"lines": lyrics.Lines,
|
"lines": lyrics.Lines,
|
||||||
|
"instrumental": lyrics.Instrumental,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
@@ -630,11 +631,15 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
|
// If filePath is provided, ONLY check file - don't fallback to online
|
||||||
|
// This allows Flutter to distinguish between "from file" vs "from online"
|
||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && lyrics != "" {
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
// File has no lyrics - return empty, let Flutter call again without filePath
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
@@ -644,6 +649,11 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return special marker for instrumental tracks
|
||||||
|
if lyricsData.Instrumental {
|
||||||
|
return "[instrumental:true]", nil
|
||||||
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
@@ -1698,6 +1708,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
if trackCover == "" {
|
if trackCover == "" {
|
||||||
trackCover = album.CoverURL
|
trackCover = album.CoverURL
|
||||||
}
|
}
|
||||||
|
// Use track number from extension, fallback to index+1 if not provided
|
||||||
|
trackNum := track.TrackNumber
|
||||||
|
if trackNum == 0 {
|
||||||
|
trackNum = i + 1
|
||||||
|
}
|
||||||
tracks[i] = map[string]interface{}{
|
tracks[i] = map[string]interface{}{
|
||||||
"id": track.ID,
|
"id": track.ID,
|
||||||
"name": track.Name,
|
"name": track.Name,
|
||||||
@@ -1707,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"cover_url": trackCover,
|
"cover_url": trackCover,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": trackNum,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
@@ -1720,6 +1735,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 +2098,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(),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,10 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
|||||||
|
|
||||||
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
// Check cache first
|
// Normalize artist name - take first artist before comma/semicolon for better matching
|
||||||
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
|
|
||||||
|
// Check cache first (use original artist name for cache key)
|
||||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
cachedCopy := *cached
|
cachedCopy := *cached
|
||||||
@@ -251,29 +254,44 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
var lyrics *LyricsResponse
|
var lyrics *LyricsResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Try exact match first
|
// Helper to check if lyrics result is valid (has lines OR is instrumental)
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
isValidResult := func(l *LyricsResponse) bool {
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match first with primary artist
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try with full artist name if different from primary
|
||||||
|
if primaryArtist != artistName {
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
|
lyrics.Source = "LRCLIB"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try with simplified track name
|
// Try with simplified track name
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search with duration matching
|
// Search with duration matching (use primary artist for search)
|
||||||
query := artistName + " " + trackName
|
query := primaryArtist + " " + trackName
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
@@ -281,9 +299,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
// Search with simplified name and duration matching
|
// Search with simplified name and duration matching
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = artistName + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
@@ -462,6 +480,24 @@ func simplifyTrackName(name string) string {
|
|||||||
return strings.TrimSpace(result)
|
return strings.TrimSpace(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeArtistName extracts the primary artist from multi-artist strings
|
||||||
|
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
|
||||||
|
// e.g., "Artist1; Artist2" -> "Artist1"
|
||||||
|
func normalizeArtistName(name string) string {
|
||||||
|
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
|
||||||
|
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||||
|
|
||||||
|
result := name
|
||||||
|
for _, sep := range separators {
|
||||||
|
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
|
||||||
|
result = result[:idx]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||||
if lrcContent == "" {
|
if lrcContent == "" {
|
||||||
return "", fmt.Errorf("empty LRC content")
|
return "", fmt.Errorf("empty LRC content")
|
||||||
|
|||||||
@@ -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.1';
|
||||||
static const String buildNumber = '62';
|
static const String buildNumber = '64';
|
||||||
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:
|
||||||
@@ -2924,6 +2962,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Failed to load lyrics'**
|
/// **'Failed to load lyrics'**
|
||||||
String get trackLyricsLoadFailed;
|
String get trackLyricsLoadFailed;
|
||||||
|
|
||||||
|
/// Action - embed lyrics into audio file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Embed Lyrics'**
|
||||||
|
String get trackEmbedLyrics;
|
||||||
|
|
||||||
|
/// Snackbar - lyrics saved to file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics embedded successfully'**
|
||||||
|
String get trackLyricsEmbedded;
|
||||||
|
|
||||||
|
/// Message when track is instrumental (no lyrics)
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Instrumental track'**
|
||||||
|
String get trackInstrumental;
|
||||||
|
|
||||||
/// Snackbar - content copied
|
/// Snackbar - content copied
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3650,6 +3706,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Albums/[2005] Album Name/'**
|
/// **'Albums/[2005] Album Name/'**
|
||||||
String get albumFolderYearAlbumSubtitle;
|
String get albumFolderYearAlbumSubtitle;
|
||||||
|
|
||||||
|
/// Album folder option with singles inside artist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist / Album + Singles'**
|
||||||
|
String get albumFolderArtistAlbumSingles;
|
||||||
|
|
||||||
|
/// Folder structure example
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist/Album/ and Artist/Singles/'**
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle;
|
||||||
|
|
||||||
/// Button - delete selected tracks
|
/// Button - delete selected tracks
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3751,6 +3819,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 +3945,7 @@ class _AppLocalizationsDelegate
|
|||||||
'nl',
|
'nl',
|
||||||
'pt',
|
'pt',
|
||||||
'ru',
|
'ru',
|
||||||
|
'tr',
|
||||||
'zh',
|
'zh',
|
||||||
].contains(locale.languageCode);
|
].contains(locale.languageCode);
|
||||||
|
|
||||||
@@ -3837,6 +4008,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';
|
||||||
|
|
||||||
@@ -1613,6 +1631,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -2001,6 +2028,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2077,4 +2111,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,4 +2098,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,6 +2098,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,4 +2098,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,4 +2098,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';
|
||||||
|
|
||||||
@@ -1610,6 +1628,15 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||||
|
|
||||||
@@ -2001,6 +2028,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||||
|
|
||||||
@@ -2077,4 +2111,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,4 +2098,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,4 +2098,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,4 +2098,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,6 +2098,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 => 'Поддержка';
|
||||||
|
|
||||||
@@ -1634,6 +1652,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
|
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
|
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
|
||||||
|
|
||||||
@@ -2029,6 +2056,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get albumFolderYearAlbumSubtitle =>
|
String get albumFolderYearAlbumSubtitle =>
|
||||||
'Альбомы/[2005] Название Альбома /';
|
'Альбомы/[2005] Название Альбома /';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||||
|
|
||||||
@@ -2109,4 +2143,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';
|
||||||
|
|
||||||
@@ -1600,6 +1618,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1988,6 +2015,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2064,6 +2098,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",
|
||||||
@@ -1176,6 +1188,12 @@
|
|||||||
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
|
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
|
||||||
"trackLyricsLoadFailed": "Failed to load lyrics",
|
"trackLyricsLoadFailed": "Failed to load lyrics",
|
||||||
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
|
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
|
||||||
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
|
"@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
|
||||||
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
|
"@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
|
||||||
|
"trackInstrumental": "Instrumental track",
|
||||||
|
"@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copied to clipboard",
|
||||||
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
|
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
|
||||||
"trackDeleteConfirmTitle": "Remove from device?",
|
"trackDeleteConfirmTitle": "Remove from device?",
|
||||||
@@ -1465,6 +1483,10 @@
|
|||||||
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
|
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
|
||||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||||
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
|
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
|
||||||
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
|
"@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
|
||||||
|
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
|
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
|
||||||
@@ -1537,5 +1559,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,146 +131,103 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
_isLoaded = true;
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await _loadFromStorage();
|
await _loadFromDatabase();
|
||||||
_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) {
|
// Migrate iOS paths if container UUID changed after app update
|
||||||
if (!seen.containsKey(key)) {
|
if (Platform.isIOS) {
|
||||||
seen[key] = result.length;
|
final pathsMigrated = await _db.migrateIosContainerPaths();
|
||||||
result.add(item);
|
if (pathsMigrated) {
|
||||||
} else {
|
_historyLog.i('Migrated iOS container paths after app update');
|
||||||
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
result.add(item);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
final jsonList = await _db.getAll();
|
||||||
}
|
final items = jsonList
|
||||||
|
.map((e) => DownloadHistoryItem.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
|
||||||
Future<void> _saveToStorage() async {
|
state = state.copyWith(items: items);
|
||||||
try {
|
_historyLog.i('Loaded ${items.length} items from SQLite database');
|
||||||
final prefs = await _prefs;
|
} catch (e, stack) {
|
||||||
final jsonList = state.items.map((e) => e.toJson()).toList();
|
_historyLog.e('Failed to load history from database: $e', e, stack);
|
||||||
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:') &&
|
if (existing != null) {
|
||||||
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
|
final updatedItems = state.items.where((i) => i.id != existing!.id).toList();
|
||||||
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) {
|
|
||||||
final updatedItems = [...state.items];
|
|
||||||
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 +235,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,10 +475,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final currentItems = state.items;
|
final currentItems = state.items;
|
||||||
final itemsById = <String, DownloadItem>{};
|
final itemsById = <String, DownloadItem>{};
|
||||||
final itemIndexById = <String, int>{};
|
final itemIndexById = <String, int>{};
|
||||||
|
int queuedCount = 0;
|
||||||
|
int downloadingCount = 0;
|
||||||
|
DownloadItem? firstDownloading;
|
||||||
for (int i = 0; i < currentItems.length; i++) {
|
for (int i = 0; i < currentItems.length; i++) {
|
||||||
final item = currentItems[i];
|
final item = currentItems[i];
|
||||||
itemsById[item.id] = item;
|
itemsById[item.id] = item;
|
||||||
itemIndexById[item.id] = i;
|
itemIndexById[item.id] = i;
|
||||||
|
if (item.status == DownloadStatus.downloading) {
|
||||||
|
downloadingCount++;
|
||||||
|
firstDownloading ??= item;
|
||||||
|
}
|
||||||
|
if (item.status == DownloadStatus.queued ||
|
||||||
|
item.status == DownloadStatus.downloading) {
|
||||||
|
queuedCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final progressUpdates = <String, _ProgressUpdate>{};
|
final progressUpdates = <String, _ProgressUpdate>{};
|
||||||
|
|
||||||
@@ -613,15 +611,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
final downloadingItems = state.items
|
if (downloadingCount > 0 && firstDownloading != null) {
|
||||||
.where((i) => i.status == DownloadStatus.downloading)
|
final trackName = downloadingCount == 1
|
||||||
.toList();
|
? firstDownloading.track.name
|
||||||
if (downloadingItems.isNotEmpty) {
|
: '$downloadingCount downloads';
|
||||||
final trackName = downloadingItems.length == 1
|
final artistName = downloadingCount == 1
|
||||||
? downloadingItems.first.track.name
|
? firstDownloading.track.artistName
|
||||||
: '${downloadingItems.length} downloads';
|
|
||||||
final artistName = downloadingItems.length == 1
|
|
||||||
? downloadingItems.first.track.artistName
|
|
||||||
: 'Downloading...';
|
: 'Downloading...';
|
||||||
|
|
||||||
int notifProgress = bytesReceived;
|
int notifProgress = bytesReceived;
|
||||||
@@ -643,11 +638,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
PlatformBridge.updateDownloadServiceProgress(
|
PlatformBridge.updateDownloadServiceProgress(
|
||||||
trackName: downloadingItems.first.track.name,
|
trackName: firstDownloading.track.name,
|
||||||
artistName: downloadingItems.first.track.artistName,
|
artistName: firstDownloading.track.artistName,
|
||||||
progress: notifProgress,
|
progress: notifProgress,
|
||||||
total: notifTotal > 0 ? notifTotal : 1,
|
total: notifTotal > 0 ? notifTotal : 1,
|
||||||
queueCount: state.queuedCount,
|
queueCount: queuedCount,
|
||||||
).catchError((_) {});
|
).catchError((_) {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,14 +720,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
if (separateSingles) {
|
if (separateSingles) {
|
||||||
final isSingle = track.isSingle;
|
final isSingle = track.isSingle;
|
||||||
|
final artistName = _sanitizeFolderName(albumArtist);
|
||||||
|
|
||||||
|
// New option: Singles folder inside Artist folder
|
||||||
|
if (albumFolderStructure == 'artist_album_singles') {
|
||||||
|
if (isSingle) {
|
||||||
|
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
|
||||||
|
await _ensureDirExists(singlesPath, label: 'Artist Singles folder');
|
||||||
|
return singlesPath;
|
||||||
|
} else {
|
||||||
|
final albumName = _sanitizeFolderName(track.albumName);
|
||||||
|
final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||||
|
await _ensureDirExists(albumPath, label: 'Artist Album folder');
|
||||||
|
return albumPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing behavior: Separate Albums/ and Singles/ at root
|
||||||
if (isSingle) {
|
if (isSingle) {
|
||||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||||
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
||||||
return singlesPath;
|
return singlesPath;
|
||||||
} else {
|
} else {
|
||||||
final albumName = _sanitizeFolderName(track.albumName);
|
final albumName = _sanitizeFolderName(track.albumName);
|
||||||
final artistName = _sanitizeFolderName(albumArtist);
|
|
||||||
final year = _extractYear(track.releaseDate);
|
final year = _extractYear(track.releaseDate);
|
||||||
String albumPath;
|
String albumPath;
|
||||||
|
|
||||||
@@ -790,7 +800,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 +1077,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;
|
||||||
@@ -1182,10 +1192,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (lrcContent.isNotEmpty) {
|
// Skip instrumental tracks (no lyrics to embed)
|
||||||
|
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
|
||||||
metadata['LYRICS'] = lrcContent;
|
metadata['LYRICS'] = lrcContent;
|
||||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||||
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||||
|
} else if (lrcContent == '[instrumental:true]') {
|
||||||
|
_log.d('Track is instrumental, skipping lyrics embedding');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to fetch lyrics for embedding: $e');
|
_log.w('Failed to fetch lyrics for embedding: $e');
|
||||||
@@ -1655,7 +1668,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 +1680,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 +1784,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 +1825,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,265 @@
|
|||||||
|
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;
|
||||||
|
final bool isYTMusicQuickPicks;
|
||||||
|
|
||||||
|
const ExploreSection({
|
||||||
|
required this.uri,
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
this.isYTMusicQuickPicks = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExploreSection.fromJson(Map<String, dynamic> json) {
|
||||||
|
final itemsList = json['items'] as List<dynamic>? ?? [];
|
||||||
|
final items = itemsList
|
||||||
|
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
final isQuickPicks = _isYTMusicQuickPicksItems(items);
|
||||||
|
return ExploreSection(
|
||||||
|
uri: json['uri'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
items: items,
|
||||||
|
isYTMusicQuickPicks: isQuickPicks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate greeting based on local device time
|
||||||
|
String _getLocalGreeting() {
|
||||||
|
final hour = DateTime.now().hour;
|
||||||
|
if (hour >= 5 && hour < 12) {
|
||||||
|
return 'Good morning';
|
||||||
|
} else if (hour >= 12 && hour < 17) {
|
||||||
|
return 'Good afternoon';
|
||||||
|
} else if (hour >= 17 && hour < 21) {
|
||||||
|
return 'Good evening';
|
||||||
|
} else {
|
||||||
|
return 'Good night';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||||
|
if (items.isEmpty) return false;
|
||||||
|
if (items.first.providerId != 'ytmusic-spotiflac') return false;
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.type != 'track') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider for explore/home feed state
|
||||||
|
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}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use local device time for greeting to avoid timezone issues
|
||||||
|
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
|
||||||
|
final localGreeting = _getLocalGreeting();
|
||||||
|
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
||||||
|
|
||||||
|
state = ExploreState(
|
||||||
|
isLoading: false,
|
||||||
|
greeting: localGreeting,
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class RecentAccessState {
|
|||||||
|
|
||||||
/// Provider for managing recent access history
|
/// Provider for managing recent access history
|
||||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||||
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
RecentAccessState build() {
|
RecentAccessState build() {
|
||||||
_loadHistory();
|
_loadHistory();
|
||||||
@@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
final json = prefs.getString(_recentAccessKey);
|
final json = prefs.getString(_recentAccessKey);
|
||||||
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
||||||
|
|
||||||
@@ -132,13 +134,13 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveHistory() async {
|
Future<void> _saveHistory() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
||||||
await prefs.setString(_recentAccessKey, json);
|
await prefs.setString(_recentAccessKey, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveHiddenDownloads() async {
|
Future<void> _saveHiddenDownloads() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
|
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
|
|||||||
const _currentMigrationVersion = 1;
|
const _currentMigrationVersion = 1;
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppSettings build() {
|
AppSettings build() {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
@@ -46,7 +48,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
final _log = AppLogger('StoreProvider');
|
final _log = AppLogger('StoreProvider');
|
||||||
|
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||||
|
|
||||||
/// Compare two semantic version strings
|
/// Compare two semantic version strings
|
||||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||||
int compareVersions(String v1, String v2) {
|
int compareVersions(String v1, String v2) {
|
||||||
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
|
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
|
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
|
|
||||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
|
|||||||
|
|
||||||
/// Notifier for managing theme settings with persistence
|
/// Notifier for managing theme settings with persistence
|
||||||
class ThemeNotifier extends Notifier<ThemeSettings> {
|
class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||||
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ThemeSettings build() {
|
ThemeSettings build() {
|
||||||
// Load settings asynchronously on first access
|
// Load settings asynchronously on first access
|
||||||
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
/// Load theme settings from SharedPreferences
|
/// Load theme settings from SharedPreferences
|
||||||
Future<void> _loadFromStorage() async {
|
Future<void> _loadFromStorage() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
final modeString = prefs.getString(kThemeModeKey);
|
final modeString = prefs.getString(kThemeModeKey);
|
||||||
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
||||||
final seedColor = prefs.getInt(kSeedColorKey);
|
final seedColor = prefs.getInt(kSeedColorKey);
|
||||||
@@ -40,7 +42,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
/// Save current settings to SharedPreferences
|
/// Save current settings to SharedPreferences
|
||||||
Future<void> _saveToStorage() async {
|
Future<void> _saveToStorage() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
||||||
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
||||||
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,88 @@ 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: Row(
|
||||||
'Downloading (${queueItems.length})',
|
children: [
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
Text(
|
||||||
fontWeight: FontWeight.bold,
|
'Downloading (${queueItems.length})',
|
||||||
),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_buildPauseResumeButton(context, ref, colorScheme),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
if (queueItems.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverList(
|
SliverList(
|
||||||
@@ -551,7 +806,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 +910,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 +939,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 +963,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 +1015,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 +1043,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 +1081,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 +1132,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(
|
||||||
@@ -873,6 +1152,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPauseResumeButton(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
) {
|
||||||
|
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||||
|
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(downloadQueueProvider.notifier).togglePause();
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
isPaused ? Icons.play_arrow : Icons.pause,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
isPaused ? context.l10n.actionResume : context.l10n.actionPause,
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(
|
Widget _buildEmptyState(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -276,6 +276,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
return 'Albums/Artist/[Year] Album/';
|
return 'Albums/Artist/[Year] Album/';
|
||||||
case 'year_album':
|
case 'year_album':
|
||||||
return 'Albums/[Year] Album/';
|
return 'Albums/[Year] Album/';
|
||||||
|
case 'artist_album_singles':
|
||||||
|
return 'Artist/Album/ + Artist/Singles/';
|
||||||
default:
|
default:
|
||||||
return 'Albums/Artist/Album Name/';
|
return 'Albums/Artist/Album Name/';
|
||||||
}
|
}
|
||||||
@@ -328,6 +330,16 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outlined),
|
||||||
|
title: Text(context.l10n.albumFolderArtistAlbumSingles),
|
||||||
|
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
|
||||||
|
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class ExtensionsPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||||
|
static final RegExp _platformExceptionPattern =
|
||||||
|
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
|
||||||
|
static final RegExp _platformExceptionSimplePattern =
|
||||||
|
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
|
||||||
|
static final RegExp _trailingNullsPattern =
|
||||||
|
RegExp(r',\s*null\s*,\s*null\)?$');
|
||||||
|
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -296,19 +304,19 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
String message = error;
|
String message = error;
|
||||||
|
|
||||||
if (message.contains('PlatformException')) {
|
if (message.contains('PlatformException')) {
|
||||||
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
final match = _platformExceptionPattern.firstMatch(message);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
message = match.group(1)?.trim() ?? message;
|
message = match.group(1)?.trim() ?? message;
|
||||||
} else {
|
} else {
|
||||||
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
final simpleMatch = _platformExceptionSimplePattern.firstMatch(message);
|
||||||
if (simpleMatch != null) {
|
if (simpleMatch != null) {
|
||||||
message = simpleMatch.group(1)?.trim() ?? message;
|
message = simpleMatch.group(1)?.trim() ?? message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
message = message.replaceAll(_trailingNullsPattern, '');
|
||||||
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
message = message.replaceAll(_leadingCommaPattern, '');
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
final RegExp _domainPattern =
|
||||||
|
RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false);
|
||||||
|
|
||||||
class LogScreen extends StatefulWidget {
|
class LogScreen extends StatefulWidget {
|
||||||
const LogScreen({super.key});
|
const LogScreen({super.key});
|
||||||
|
|
||||||
@@ -13,6 +16,7 @@ class LogScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LogScreenState extends State<LogScreen> {
|
class _LogScreenState extends State<LogScreen> {
|
||||||
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
String _selectedLevel = 'ALL';
|
String _selectedLevel = 'ALL';
|
||||||
@@ -633,7 +637,7 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
combined.contains('connection refused')) {
|
combined.contains('connection refused')) {
|
||||||
hasISPBlocking = true;
|
hasISPBlocking = true;
|
||||||
|
|
||||||
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
final domainMatch = _domainPattern.firstMatch(combined);
|
||||||
if (domainMatch != null) {
|
if (domainMatch != null) {
|
||||||
blockedDomains.add(domainMatch.group(1)!);
|
blockedDomains.add(domainMatch.group(1)!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -25,14 +25,20 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
|
|||||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
bool _fileExists = false;
|
bool _fileExists = false;
|
||||||
int? _fileSize;
|
int? _fileSize;
|
||||||
String? _lyrics;
|
String? _lyrics; // Cleaned lyrics for display (no timestamps)
|
||||||
|
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||||
bool _lyricsLoading = false;
|
bool _lyricsLoading = false;
|
||||||
String? _lyricsError;
|
String? _lyricsError;
|
||||||
Color? _dominantColor;
|
Color? _dominantColor;
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
|
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
||||||
|
bool _isEmbedding = false; // Track embed operation in progress
|
||||||
|
bool _isInstrumental = false; // Track if detected as instrumental
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
static final RegExp _lrcTimestampPattern =
|
static final RegExp _lrcTimestampPattern =
|
||||||
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
||||||
|
static final RegExp _lrcMetadataPattern =
|
||||||
|
RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
||||||
static const List<String> _months = [
|
static const List<String> _months = [
|
||||||
'Jan',
|
'Jan',
|
||||||
'Feb',
|
'Feb',
|
||||||
@@ -61,7 +67,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 +89,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 (_) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,18 +850,62 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_lyrics != null)
|
else if (_isInstrumental)
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxHeight: 300),
|
padding: const EdgeInsets.all(16),
|
||||||
child: SingleChildScrollView(
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||||
_lyrics!,
|
borderRadius: BorderRadius.circular(12),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
),
|
||||||
color: colorScheme.onSurface,
|
child: Row(
|
||||||
height: 1.6,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.music_note, color: colorScheme.tertiary, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
context.l10n.trackInstrumental,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_lyrics != null)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 300),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Text(
|
||||||
|
_lyrics!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Show "Embed Lyrics" button if lyrics are from online (not already embedded)
|
||||||
|
if (!_lyricsEmbedded && _fileExists) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: _isEmbedding ? null : _embedLyrics,
|
||||||
|
icon: _isEmbedding
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save_alt),
|
||||||
|
label: Text(context.l10n.trackEmbedLyrics),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Center(
|
Center(
|
||||||
@@ -879,26 +927,57 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_lyricsLoading = true;
|
_lyricsLoading = true;
|
||||||
_lyricsError = null;
|
_lyricsError = null;
|
||||||
|
_isInstrumental = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert duration from seconds to milliseconds
|
// Convert duration from seconds to milliseconds
|
||||||
final durationMs = (item.duration ?? 0) * 1000;
|
final durationMs = (item.duration ?? 0) * 1000;
|
||||||
|
|
||||||
// Add timeout to prevent infinite loading
|
// First, check if lyrics are embedded in the file
|
||||||
|
if (_fileExists) {
|
||||||
|
final embeddedResult = await PlatformBridge.getLyricsLRC(
|
||||||
|
'',
|
||||||
|
item.trackName,
|
||||||
|
item.artistName,
|
||||||
|
filePath: cleanFilePath,
|
||||||
|
durationMs: 0,
|
||||||
|
).timeout(const Duration(seconds: 5), onTimeout: () => '');
|
||||||
|
|
||||||
|
if (embeddedResult.isNotEmpty) {
|
||||||
|
// Lyrics found in file
|
||||||
|
if (mounted) {
|
||||||
|
final cleanLyrics = _cleanLrcForDisplay(embeddedResult);
|
||||||
|
setState(() {
|
||||||
|
_lyrics = cleanLyrics;
|
||||||
|
_lyricsEmbedded = true;
|
||||||
|
_lyricsLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No embedded lyrics, fetch from online
|
||||||
final result = await PlatformBridge.getLyricsLRC(
|
final result = await PlatformBridge.getLyricsLRC(
|
||||||
item.spotifyId ?? '',
|
item.spotifyId ?? '',
|
||||||
item.trackName,
|
item.trackName,
|
||||||
item.artistName,
|
item.artistName,
|
||||||
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
filePath: null, // Don't check file again
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 20),
|
const Duration(seconds: 20),
|
||||||
onTimeout: () => '', // Return empty string on timeout
|
onTimeout: () => '',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
if (result.isEmpty) {
|
// Check for instrumental marker
|
||||||
|
if (result == '[instrumental:true]') {
|
||||||
|
setState(() {
|
||||||
|
_isInstrumental = true;
|
||||||
|
_lyricsLoading = false;
|
||||||
|
});
|
||||||
|
} else if (result.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
@@ -907,6 +986,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final cleanLyrics = _cleanLrcForDisplay(result);
|
final cleanLyrics = _cleanLrcForDisplay(result);
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyrics = cleanLyrics;
|
_lyrics = cleanLyrics;
|
||||||
|
_rawLyrics = result; // Keep raw LRC with timestamps for embedding
|
||||||
|
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -924,12 +1005,58 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _embedLyrics() async {
|
||||||
|
if (_isEmbedding || _rawLyrics == null || !_fileExists) return;
|
||||||
|
|
||||||
|
setState(() => _isEmbedding = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use raw LRC content directly - it already has timestamps and metadata
|
||||||
|
final result = await PlatformBridge.embedLyricsToFile(
|
||||||
|
cleanFilePath,
|
||||||
|
_rawLyrics!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
if (result['success'] == true) {
|
||||||
|
setState(() {
|
||||||
|
_lyricsEmbedded = true;
|
||||||
|
_isEmbedding = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.trackLyricsEmbedded)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState(() => _isEmbedding = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(result['error'] ?? 'Failed to embed lyrics')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isEmbedding = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _cleanLrcForDisplay(String lrc) {
|
String _cleanLrcForDisplay(String lrc) {
|
||||||
final lines = lrc.split('\n');
|
final lines = lrc.split('\n');
|
||||||
final cleanLines = <String>[];
|
final cleanLines = <String>[];
|
||||||
|
|
||||||
for (final line in lines) {
|
for (final line in lines) {
|
||||||
final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim();
|
final trimmedLine = line.trim();
|
||||||
|
|
||||||
|
// Skip metadata tags
|
||||||
|
if (_lrcMetadataPattern.hasMatch(trimmedLine)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove timestamp and clean up
|
||||||
|
final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim();
|
||||||
if (cleanLine.isNotEmpty) {
|
if (cleanLine.isNotEmpty) {
|
||||||
cleanLines.add(cleanLine);
|
cleanLines.add(cleanLine);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
|
|
||||||
class CsvImportService {
|
class CsvImportService {
|
||||||
static final _log = AppLogger('CsvImportService');
|
static final _log = AppLogger('CsvImportService');
|
||||||
|
static final RegExp _lineSplitPattern = RegExp(r'\r\n|\r|\n');
|
||||||
|
|
||||||
static Future<List<Track>> pickAndParseCsv({
|
static Future<List<Track>> pickAndParseCsv({
|
||||||
void Function(int current, int total)? onProgress,
|
void Function(int current, int total)? onProgress,
|
||||||
@@ -123,7 +124,7 @@ class CsvImportService {
|
|||||||
|
|
||||||
static List<Track> _parseCsv(String content) {
|
static List<Track> _parseCsv(String content) {
|
||||||
final List<Track> tracks = [];
|
final List<Track> tracks = [];
|
||||||
final lines = content.split(RegExp(r'\r\n|\r|\n'));
|
final lines = content.split(_lineSplitPattern);
|
||||||
if (lines.isEmpty) return tracks;
|
if (lines.isEmpty) return tracks;
|
||||||
|
|
||||||
int startIdx = 0;
|
int startIdx = 0;
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
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');
|
||||||
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
/// Cached current iOS container path for path normalization
|
||||||
|
String? _currentContainerPath;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== iOS Path Normalization ====================
|
||||||
|
|
||||||
|
/// Pattern to match iOS container paths
|
||||||
|
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
|
||||||
|
static final _iosContainerPattern = RegExp(
|
||||||
|
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Initialize and cache the current iOS container path
|
||||||
|
Future<void> _initContainerPath() async {
|
||||||
|
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final docDir = await getApplicationDocumentsDirectory();
|
||||||
|
// Extract container path up to and including the UUID folder
|
||||||
|
// e.g., /var/mobile/Containers/Data/Application/UUID/
|
||||||
|
final match = _iosContainerPattern.firstMatch(docDir.path);
|
||||||
|
if (match != null) {
|
||||||
|
_currentContainerPath = match.group(0);
|
||||||
|
_log.d('iOS container path: $_currentContainerPath');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to get iOS container path: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize iOS file path by replacing old container UUID with current one
|
||||||
|
/// This fixes the issue where iOS changes container UUID after app updates
|
||||||
|
String _normalizeIosPath(String? filePath) {
|
||||||
|
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||||
|
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||||
|
|
||||||
|
// Check if path contains an iOS container path
|
||||||
|
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||||
|
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
|
||||||
|
if (normalized != filePath) {
|
||||||
|
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate iOS paths in database to use current container UUID
|
||||||
|
/// This is called once after app update if container changed
|
||||||
|
Future<bool> migrateIosContainerPaths() async {
|
||||||
|
if (!Platform.isIOS) return false;
|
||||||
|
|
||||||
|
await _initContainerPath();
|
||||||
|
if (_currentContainerPath == null) return false;
|
||||||
|
|
||||||
|
final prefs = await _prefs;
|
||||||
|
final lastContainer = prefs.getString('ios_last_container_path');
|
||||||
|
|
||||||
|
// Skip if container hasn't changed
|
||||||
|
if (lastContainer == _currentContainerPath) {
|
||||||
|
_log.d('iOS container path unchanged, skipping migration');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final db = await database;
|
||||||
|
|
||||||
|
// Get all items with iOS paths
|
||||||
|
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||||
|
int updatedCount = 0;
|
||||||
|
final batch = db.batch();
|
||||||
|
|
||||||
|
for (final row in rows) {
|
||||||
|
final id = row['id'] as String;
|
||||||
|
final oldPath = row['file_path'] as String?;
|
||||||
|
|
||||||
|
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||||
|
final newPath = _normalizeIosPath(oldPath);
|
||||||
|
if (newPath != oldPath) {
|
||||||
|
batch.update(
|
||||||
|
'history',
|
||||||
|
{'file_path': newPath},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedCount > 0) {
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current container path
|
||||||
|
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||||
|
|
||||||
|
_log.i('iOS path migration complete: $updatedCount paths updated');
|
||||||
|
return updatedCount > 0;
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log.e('iOS path migration failed: $e', e, stack);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate data from SharedPreferences to SQLite
|
||||||
|
/// Returns true if migration was performed, false if already migrated
|
||||||
|
Future<bool> migrateFromSharedPreferences() async {
|
||||||
|
final prefs = await _prefs;
|
||||||
|
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)
|
||||||
|
/// Also normalizes iOS paths if container UUID changed
|
||||||
|
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': _normalizeIosPath(row['file_path'] as String?),
|
||||||
|
'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,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = _colorCache[imageUrl];
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, {
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ class ShareIntentService {
|
|||||||
factory ShareIntentService() => _instance;
|
factory ShareIntentService() => _instance;
|
||||||
ShareIntentService._internal();
|
ShareIntentService._internal();
|
||||||
|
|
||||||
|
static final RegExp _spotifyUriPattern =
|
||||||
|
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
|
||||||
|
static final RegExp _spotifyUrlPattern = RegExp(
|
||||||
|
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||||
|
);
|
||||||
|
|
||||||
final _sharedUrlController = StreamController<String>.broadcast();
|
final _sharedUrlController = StreamController<String>.broadcast();
|
||||||
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
|
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
@@ -57,14 +63,12 @@ class ShareIntentService {
|
|||||||
String? _extractSpotifyUrl(String text) {
|
String? _extractSpotifyUrl(String text) {
|
||||||
if (text.isEmpty) return null;
|
if (text.isEmpty) return null;
|
||||||
|
|
||||||
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
|
final uriMatch = _spotifyUriPattern.firstMatch(text);
|
||||||
if (uriMatch != null) {
|
if (uriMatch != null) {
|
||||||
return uriMatch.group(0);
|
return uriMatch.group(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
final urlMatch = RegExp(
|
final urlMatch = _spotifyUrlPattern.firstMatch(text);
|
||||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
|
||||||
).firstMatch(text);
|
|
||||||
if (urlMatch != null) {
|
if (urlMatch != null) {
|
||||||
final fullUrl = urlMatch.group(0)!;
|
final fullUrl = urlMatch.group(0)!;
|
||||||
final queryIndex = fullUrl.indexOf('?');
|
final queryIndex = fullUrl.indexOf('?');
|
||||||
|
|||||||
@@ -159,15 +159,17 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
||||||
|
final tagLower = tag?.toLowerCase();
|
||||||
|
final searchLower = search?.toLowerCase();
|
||||||
|
|
||||||
return _entries.where((entry) {
|
return _entries.where((entry) {
|
||||||
if (level != null && level != 'ALL' && entry.level != level) {
|
if (level != null && level != 'ALL' && entry.level != level) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) {
|
if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (search != null && search.isNotEmpty) {
|
if (searchLower != null && searchLower.isNotEmpty) {
|
||||||
final searchLower = search.toLowerCase();
|
|
||||||
return entry.message.toLowerCase().contains(searchLower) ||
|
return entry.message.toLowerCase().contains(searchLower) ||
|
||||||
entry.tag.toLowerCase().contains(searchLower) ||
|
entry.tag.toLowerCase().contains(searchLower) ||
|
||||||
(entry.error?.toLowerCase().contains(searchLower) ?? false);
|
(entry.error?.toLowerCase().contains(searchLower) ?? false);
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
bool _isDownloading = false;
|
bool _isDownloading = false;
|
||||||
double _progress = 0;
|
double _progress = 0;
|
||||||
String _statusText = '';
|
String _statusText = '';
|
||||||
|
static final RegExp _whatsNewPattern =
|
||||||
|
RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false);
|
||||||
|
static final RegExp _cutoffPattern =
|
||||||
|
RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false);
|
||||||
|
static final RegExp _sectionPattern = RegExp(r'^#{1,3}\s*(.+)$');
|
||||||
|
static final RegExp _listPattern = RegExp(r'^[-*]\s+(.+)$');
|
||||||
|
static final RegExp _subListPattern = RegExp(r'^\s+[-*]\s+(.+)$');
|
||||||
|
static final RegExp _boldPattern = RegExp(r'\*\*([^*]+)\*\*');
|
||||||
|
static final RegExp _codePattern = RegExp(r'`([^`]+)`');
|
||||||
|
|
||||||
Future<void> _downloadAndInstall() async {
|
Future<void> _downloadAndInstall() async {
|
||||||
final apkUrl = widget.updateInfo.apkDownloadUrl;
|
final apkUrl = widget.updateInfo.apkDownloadUrl;
|
||||||
@@ -293,12 +302,12 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
String _formatChangelog(String changelog) {
|
String _formatChangelog(String changelog) {
|
||||||
var content = changelog;
|
var content = changelog;
|
||||||
|
|
||||||
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
|
final whatsNewMatch = _whatsNewPattern.firstMatch(content);
|
||||||
if (whatsNewMatch != null) {
|
if (whatsNewMatch != null) {
|
||||||
content = content.substring(whatsNewMatch.end);
|
content = content.substring(whatsNewMatch.end);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
|
final cutoffMatch = _cutoffPattern.firstMatch(content);
|
||||||
if (cutoffMatch != null) {
|
if (cutoffMatch != null) {
|
||||||
content = content.substring(0, cutoffMatch.start);
|
content = content.substring(0, cutoffMatch.start);
|
||||||
}
|
}
|
||||||
@@ -310,7 +319,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
line = line.trim();
|
line = line.trim();
|
||||||
if (line.isEmpty) continue;
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
final sectionMatch = _sectionPattern.firstMatch(line);
|
||||||
if (sectionMatch != null) {
|
if (sectionMatch != null) {
|
||||||
final section = sectionMatch.group(1)?.trim();
|
final section = sectionMatch.group(1)?.trim();
|
||||||
if (section != null && section.isNotEmpty) {
|
if (section != null && section.isNotEmpty) {
|
||||||
@@ -320,19 +329,19 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
final listMatch = _listPattern.firstMatch(line);
|
||||||
if (listMatch != null) {
|
if (listMatch != null) {
|
||||||
var itemText = listMatch.group(1) ?? '';
|
var itemText = listMatch.group(1) ?? '';
|
||||||
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? '');
|
||||||
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
|
itemText = itemText.replaceAllMapped(_codePattern, (m) => m.group(1) ?? '');
|
||||||
formattedLines.add('• $itemText');
|
formattedLines.add('• $itemText');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
final subListMatch = _subListPattern.firstMatch(line);
|
||||||
if (subListMatch != null) {
|
if (subListMatch != null) {
|
||||||
var itemText = subListMatch.group(1) ?? '';
|
var itemText = subListMatch.group(1) ?? '';
|
||||||
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? '');
|
||||||
formattedLines.add(' - $itemText');
|
formattedLines.add(' - $itemText');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.1+64
|
||||||
|
|
||||||
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.1+64
|
||||||
|
|
||||||
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
|
||||||
|
|||||||