mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac4f555f6 | |||
| 098544393e | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| a4899144c5 | |||
| 808083c938 | |||
| 7e41ab4460 | |||
| 75a2bec8d5 | |||
| c35857bb61 | |||
| 2c897992c5 | |||
| 7d5cb574c6 | |||
| c582f96cf6 | |||
| 8fab3f60a7 | |||
| c6e981b3a1 | |||
| f0c5c5660a | |||
| 9c647bb31b | |||
| e1e82ac586 | |||
| 585d6da98d | |||
| bc279dd7fd | |||
| f2fdead6d3 | |||
| f66ccb4741 | |||
| 32c10c2b23 | |||
| 05674d9586 | |||
| 11bda9aae5 | |||
| 02c803385c | |||
| 8fe7a1e756 | |||
| 4a61ffea8d | |||
| 91548691ad | |||
| ab2d671760 | |||
| 5532d0a7d9 | |||
| 277e7719d3 | |||
| d6cb9fc261 | |||
| e6a857335f | |||
| e82e3a8343 | |||
| 6d812c76c2 | |||
| 5af4bb7ade | |||
| 030d66fd65 | |||
| c929f8d0a6 | |||
| 6fb50cfc67 | |||
| ebcdcf40dc | |||
| 76a05e717b | |||
| 062ce31cf7 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| d76d020cfe | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| e83fd66023 | |||
| d49bab403d | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| b39ec41255 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 |
@@ -309,32 +309,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Full history needed for git-cliff
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Generate changelog with git-cliff
|
||||||
id: changelog
|
id: changelog
|
||||||
|
uses: orhun/git-cliff-action@v4
|
||||||
|
with:
|
||||||
|
config: cliff.toml
|
||||||
|
args: --latest --strip header
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OUTPUT: /tmp/changelog.txt
|
||||||
|
|
||||||
|
- name: Show generated changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
echo "Generated changelog:"
|
||||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
|
||||||
|
|
||||||
echo "Looking for version: $VERSION_NUM"
|
|
||||||
|
|
||||||
# Extract changelog section for this version using sed
|
|
||||||
# Find the line with version, then print until next version header or end
|
|
||||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
|
||||||
|
|
||||||
# If no changelog found, use default message
|
|
||||||
if [ -z "$CHANGELOG" ]; then
|
|
||||||
echo "No changelog found for version $VERSION_NUM"
|
|
||||||
CHANGELOG="See CHANGELOG.md for details."
|
|
||||||
else
|
|
||||||
echo "Found changelog content"
|
|
||||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
|
||||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save to file for multiline support
|
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
|
||||||
echo "Extracted changelog:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
@@ -352,15 +342,13 @@ jobs:
|
|||||||
- name: Prepare release body
|
- name: Prepare release body
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cat > /tmp/release_body.txt << 'HEADER'
|
|
||||||
### What's New
|
|
||||||
HEADER
|
|
||||||
|
|
||||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
|
||||||
|
|
||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
|
# Start with git-cliff changelog
|
||||||
|
cp /tmp/changelog.txt /tmp/release_body.txt
|
||||||
|
|
||||||
|
# Append download section
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -404,6 +392,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
@@ -417,52 +407,40 @@ jobs:
|
|||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Generate changelog with git-cliff for Telegram
|
||||||
|
uses: orhun/git-cliff-action@v4
|
||||||
|
with:
|
||||||
|
config: cliff.toml
|
||||||
|
args: --latest --strip all
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OUTPUT: /tmp/cliff_tg.txt
|
||||||
|
|
||||||
|
- name: Convert changelog for Telegram
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||||
VERSION_NUM=${VERSION#v}
|
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||||
|
|
||||||
# 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
|
else
|
||||||
# Convert GitHub Markdown to Telegram HTML:
|
# Convert Markdown to Telegram HTML
|
||||||
# - **text** → <b>text</b>
|
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||||
# - `code` → <code>code</code>
|
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||||
# - ### Header → <b>Header</b>
|
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||||
# - Escape HTML special chars first
|
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||||
# - Remove > blockquote prefix
|
|
||||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
|
||||||
sed 's/^> //' | \
|
|
||||||
sed 's/&/\&/g' | \
|
sed 's/&/\&/g' | \
|
||||||
sed 's/</\</g' | \
|
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/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^- /• /g' | \
|
sed 's/^- /• /g')
|
||||||
sed 's/^ - / ◦ /g')
|
|
||||||
|
# Truncate for Telegram 4096 char limit
|
||||||
# Take first 2500 characters, then cut at last complete line
|
|
||||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
# Check if truncated
|
|
||||||
FULL_LEN=${#FULL_CHANGELOG}
|
|
||||||
if [ $FULL_LEN -gt 2500 ]; then
|
|
||||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "Telegram changelog:"
|
||||||
echo "DEBUG: Final changelog:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Send to Telegram Channel
|
- name: Send to Telegram Channel
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ Thumbs.db
|
|||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
|
|
||||||
|
# Design assets (banners, mockups)
|
||||||
|
design/
|
||||||
|
|
||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.7.2] - 2026-03-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz.
|
||||||
|
- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system:
|
||||||
|
- When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`)
|
||||||
|
- Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes
|
||||||
|
- `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS
|
||||||
|
- New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark`
|
||||||
|
- New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()`
|
||||||
|
- All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()`
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension.
|
||||||
|
- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability
|
||||||
|
- Back buttons use `MaterialLocalizations.backButtonTooltip`
|
||||||
|
- Close buttons use `MaterialLocalizations.closeButtonTooltip`
|
||||||
|
- Menu buttons use `MaterialLocalizations.showMenuTooltip`
|
||||||
|
- Search buttons use `MaterialLocalizations.searchFieldLabel`
|
||||||
|
- Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh"
|
||||||
|
- Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority)
|
||||||
|
- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information
|
||||||
|
- Album tiles in Artist screen: announces selection state and album name
|
||||||
|
- Recently downloaded track tiles in Home tab: announces track name and artist
|
||||||
|
- Explore items (albums/artists/playlists) in Home tab: announces item type and name
|
||||||
|
- Color palette picker in Appearance settings: announces selected state and color hex value
|
||||||
|
- Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements
|
||||||
|
- Queue tab playlist cards: announces playlist name and item count
|
||||||
|
- Queue tab downloaded album cards: announces album name, artist, and track count
|
||||||
|
- Queue tab local album cards: announces album name, artist, and track count
|
||||||
|
- Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon
|
||||||
|
- Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines
|
||||||
|
- `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short)
|
||||||
|
- `log_screen.dart`: Fixed `SliverAppBar` indentation alignment
|
||||||
|
- `donate_page.dart`: Reformatted ternary expressions and `_cr` function body
|
||||||
|
- `library_tracks_folder_screen.dart`: Minor line-break formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.7.1] - 2026-03-06
|
## [3.7.1] - 2026-03-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="icon.png" width="128" />
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||||
|
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||||
|
</picture>
|
||||||
|
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/17247">
|
||||||

|
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||

|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -24,6 +23,17 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||||
@@ -43,18 +53,13 @@ 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
|
||||||
|
|
||||||
## Telegram
|
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
|
||||||
[](https://t.me/spotiflac_chat)
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
**Q: Why is my download failing with "Song not found"?**
|
||||||
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
|
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
||||||
|
|
||||||
**Q: Why are some tracks downloading in lower quality?**
|
**Q: Why are some tracks downloading in lower quality?**
|
||||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
|
||||||
|
|
||||||
**Q: Can I download playlists?**
|
**Q: Can I download playlists?**
|
||||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
@@ -75,23 +80,6 @@ _If this software is useful and brings you value, consider supporting the projec
|
|||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
|
||||||
## Disclaimer
|
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
|
||||||
|
|
||||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
|
||||||
|
|
||||||
You are solely responsible for:
|
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
|
||||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
|
||||||
3. Any legal consequences resulting from the misuse of this tool.
|
|
||||||
|
|
||||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
|
||||||
|
|
||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||||
|
|||||||
@@ -766,6 +766,27 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val response = downloader(req.toString())
|
val response = downloader(req.toString())
|
||||||
val respObj = JSONObject(response)
|
val respObj = JSONObject(response)
|
||||||
if (respObj.optBoolean("success", false)) {
|
if (respObj.optBoolean("success", false)) {
|
||||||
|
// Extension providers write to a local temp path instead of the SAF FD.
|
||||||
|
// Copy the local file into the SAF document so it is not empty.
|
||||||
|
val goFilePath = respObj.optString("file_path", "")
|
||||||
|
if (goFilePath.isNotEmpty() &&
|
||||||
|
!goFilePath.startsWith("content://") &&
|
||||||
|
!goFilePath.startsWith("/proc/self/fd/")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val srcFile = java.io.File(goFilePath)
|
||||||
|
if (srcFile.exists() && srcFile.length() > 0) {
|
||||||
|
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||||
|
srcFile.inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srcFile.delete()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
respObj.put("file_path", document.uri.toString())
|
respObj.put("file_path", document.uri.toString())
|
||||||
respObj.put("file_name", document.name ?: fileName)
|
respObj.put("file_name", document.name ?: fileName)
|
||||||
} else {
|
} else {
|
||||||
@@ -786,6 +807,72 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent DocumentFile directory for a SAF document URI.
|
||||||
|
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
|
||||||
|
* Returns a DocumentFile that supports findFile() for sibling lookup.
|
||||||
|
*/
|
||||||
|
private fun safParentDir(childUri: Uri): DocumentFile? {
|
||||||
|
try {
|
||||||
|
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
||||||
|
if (docId.isNullOrEmpty()) return null
|
||||||
|
|
||||||
|
// Document IDs typically look like "primary:Music/Album/file.cue"
|
||||||
|
// Parent would be "primary:Music/Album"
|
||||||
|
val lastSlash = docId.lastIndexOf('/')
|
||||||
|
if (lastSlash <= 0) return null
|
||||||
|
|
||||||
|
val parentDocId = docId.substring(0, lastSlash)
|
||||||
|
|
||||||
|
// Build a tree document URI for the parent so it supports listing/findFile
|
||||||
|
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
|
||||||
|
if (treeDocId.isNullOrEmpty()) return null
|
||||||
|
|
||||||
|
val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
|
||||||
|
childUri, parentDocId
|
||||||
|
)
|
||||||
|
return DocumentFile.fromTreeUri(this, parentUri)
|
||||||
|
?: DocumentFile.fromSingleUri(this, parentUri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the audio filename referenced by a CUE sheet file.
|
||||||
|
* Reads the FILE "name" TYPE line from the .cue text.
|
||||||
|
* Returns just the filename (no path), or null if not found.
|
||||||
|
*/
|
||||||
|
private fun extractCueAudioFileName(cueTempPath: String): String? {
|
||||||
|
try {
|
||||||
|
val lines = File(cueTempPath).readLines()
|
||||||
|
for (line in lines) {
|
||||||
|
val trimmed = line.trim().let { l ->
|
||||||
|
// Strip BOM
|
||||||
|
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
|
||||||
|
}
|
||||||
|
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
|
||||||
|
val rest = trimmed.substring(5).trim()
|
||||||
|
// Parse: "filename" TYPE or filename TYPE
|
||||||
|
val filename = if (rest.startsWith("\"")) {
|
||||||
|
val endQuote = rest.indexOf('"', 1)
|
||||||
|
if (endQuote > 0) rest.substring(1, endQuote) else rest
|
||||||
|
} else {
|
||||||
|
// Last word is the type, everything else is the filename
|
||||||
|
val parts = rest.split("\\s+".toRegex())
|
||||||
|
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
|
||||||
|
}
|
||||||
|
// Return just the filename (strip any path separators)
|
||||||
|
return filename.substringAfterLast("/").substringAfterLast("\\")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun scanSafTree(treeUriStr: String): String {
|
private fun scanSafTree(treeUriStr: String): String {
|
||||||
if (treeUriStr.isBlank()) return "[]"
|
if (treeUriStr.isBlank()) return "[]"
|
||||||
|
|
||||||
@@ -799,8 +886,10 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
it.currentFile = "Scanning folders..."
|
it.currentFile = "Scanning folders..."
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||||
|
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
|
||||||
|
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
var traversalErrors = 0
|
var traversalErrors = 0
|
||||||
|
|
||||||
@@ -849,7 +938,9 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
} else if (child.isFile) {
|
} else if (child.isFile) {
|
||||||
val name = child.name ?: continue
|
val name = child.name ?: continue
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
|
if (ext == "cue") {
|
||||||
|
cueFiles.add(child to dir)
|
||||||
|
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
|
||||||
audioFiles.add(child to path)
|
audioFiles.add(child to path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -864,11 +955,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val totalItems = audioFiles.size + cueFiles.size
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.totalFiles = audioFiles.size
|
it.totalFiles = totalItems
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioFiles.isEmpty()) {
|
if (audioFiles.isEmpty() && cueFiles.isEmpty()) {
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.isComplete = true
|
it.isComplete = true
|
||||||
it.progressPct = 100.0
|
it.progressPct = 100.0
|
||||||
@@ -880,12 +972,138 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = traversalErrors
|
var errors = traversalErrors
|
||||||
|
|
||||||
|
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
|
||||||
|
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for ((cueDoc, parentDir) in cueFiles) {
|
||||||
|
if (safScanCancel) {
|
||||||
|
updateSafScanProgress { it.isComplete = true }
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
|
||||||
|
updateSafScanProgress { it.currentFile = cueName }
|
||||||
|
|
||||||
|
var tempCuePath: String? = null
|
||||||
|
var tempAudioPath: String? = null
|
||||||
|
try {
|
||||||
|
// Copy CUE to temp
|
||||||
|
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||||
|
if (tempCuePath == null) {
|
||||||
|
errors++
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}")
|
||||||
|
scanned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the audio filename from the CUE sheet text
|
||||||
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
|
// Find the referenced audio file as a sibling in the same SAF directory
|
||||||
|
var audioDoc: DocumentFile? = null
|
||||||
|
if (!audioFileName.isNullOrBlank()) {
|
||||||
|
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try common audio extensions with the CUE base name
|
||||||
|
if (audioDoc == null) {
|
||||||
|
val cueBaseName = cueName.substringBeforeLast('.')
|
||||||
|
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
||||||
|
for (ext in commonExts) {
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
// Try uppercase
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioDoc == null) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
|
||||||
|
errors++
|
||||||
|
scanned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark this audio file so we skip it in the regular audio pass
|
||||||
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
|
|
||||||
|
// Copy audio to same temp dir so Go can resolve it
|
||||||
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
|
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||||
|
|
||||||
|
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
||||||
|
if (tempAudioPath == null) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName")
|
||||||
|
errors++
|
||||||
|
scanned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename temp audio to its original name so Go can find it by name
|
||||||
|
val renamedAudio = File(tempDir, audioName)
|
||||||
|
val tempAudioFile = File(tempAudioPath)
|
||||||
|
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||||
|
tempAudioFile.renameTo(renamedAudio)
|
||||||
|
tempAudioPath = renamedAudio.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
|
||||||
|
|
||||||
|
// Call Go to produce library scan entries for each CUE track
|
||||||
|
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||||
|
tempCuePath,
|
||||||
|
tempDir,
|
||||||
|
cueDoc.uri.toString(),
|
||||||
|
cueLastModified
|
||||||
|
)
|
||||||
|
|
||||||
|
val cueArray = JSONArray(cueResultsJson)
|
||||||
|
for (j in 0 until cueArray.length()) {
|
||||||
|
results.put(cueArray.getJSONObject(j))
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF scan: CUE $cueName -> ${cueArray.length()} tracks"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errors++
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
|
||||||
|
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned++
|
||||||
|
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||||
|
updateSafScanProgress {
|
||||||
|
it.scannedFiles = scanned
|
||||||
|
it.errorCount = errors
|
||||||
|
it.progressPct = pct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
||||||
for ((doc, _) in audioFiles) {
|
for ((doc, _) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
return "[]"
|
return "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip audio files that are represented by CUE track entries
|
||||||
|
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||||
|
scanned++
|
||||||
|
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||||
|
updateSafScanProgress {
|
||||||
|
it.scannedFiles = scanned
|
||||||
|
it.progressPct = pct
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.currentFile = name
|
it.currentFile = name
|
||||||
@@ -926,7 +1144,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scanned++
|
scanned++
|
||||||
val pct = scanned.toDouble() / audioFiles.size.toDouble() * 100.0
|
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.scannedFiles = scanned
|
it.scannedFiles = scanned
|
||||||
it.errorCount = errors
|
it.errorCount = errors
|
||||||
@@ -944,6 +1162,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Incremental SAF tree scan - only scans new or modified files.
|
* Incremental SAF tree scan - only scans new or modified files.
|
||||||
|
* Supports .cue sheets: expands them into virtual track entries and
|
||||||
|
* deduplicates audio files referenced by CUE sheets.
|
||||||
* @param treeUriStr The SAF tree URI to scan
|
* @param treeUriStr The SAF tree URI to scan
|
||||||
* @param existingFilesJson JSON object mapping file URI -> lastModified timestamp
|
* @param existingFilesJson JSON object mapping file URI -> lastModified timestamp
|
||||||
* @return JSON object with new/changed files and removed URIs
|
* @return JSON object with new/changed files and removed URIs
|
||||||
@@ -986,13 +1206,29 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
it.currentFile = "Scanning folders..."
|
it.currentFile = "Scanning folders..."
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
||||||
|
// CUE files to scan: (cueDoc, parentDir, lastModified)
|
||||||
|
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||||
|
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
|
||||||
|
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val currentUris = mutableSetOf<String>()
|
val currentUris = mutableSetOf<String>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
var traversalErrors = 0
|
var traversalErrors = 0
|
||||||
|
|
||||||
// Collect all audio files with lastModified
|
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
|
||||||
|
// Virtual paths look like "content://...album.cue#track01".
|
||||||
|
// We need this to preserve virtual paths for unchanged CUE files.
|
||||||
|
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
|
||||||
|
for (key in existingFiles.keys) {
|
||||||
|
val hashIdx = key.indexOf("#track")
|
||||||
|
if (hashIdx > 0) {
|
||||||
|
val baseCueUri = key.substring(0, hashIdx)
|
||||||
|
existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all files with lastModified
|
||||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||||
queue.add(root to "")
|
queue.add(root to "")
|
||||||
|
|
||||||
@@ -1055,7 +1291,27 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val name = child.name ?: continue
|
val name = child.name ?: continue
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
|
|
||||||
|
if (ext == "cue") {
|
||||||
|
val lastModified = try {
|
||||||
|
child.lastModified()
|
||||||
|
} catch (_: Exception) { 0L }
|
||||||
|
|
||||||
|
// Check if any virtual track from this CUE exists with matching modTime
|
||||||
|
val virtualPaths = existingCueVirtualPaths[uriStr]
|
||||||
|
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
|
||||||
|
|
||||||
|
if (existingModified != null && existingModified == lastModified) {
|
||||||
|
// CUE is unchanged — mark virtual paths as current so they aren't removed
|
||||||
|
unchangedCueFiles.add(child to dir)
|
||||||
|
for (vp in virtualPaths) {
|
||||||
|
currentUris.add(vp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// CUE is new or modified — needs scanning
|
||||||
|
cueFilesToScan.add(Triple(child, dir, lastModified))
|
||||||
|
}
|
||||||
|
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
|
||||||
val existingModified = existingFiles[uriStr]
|
val existingModified = existingFiles[uriStr]
|
||||||
val lastModified = try {
|
val lastModified = try {
|
||||||
child.lastModified()
|
child.lastModified()
|
||||||
@@ -1083,13 +1339,14 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
// Find removed files (in existing but not in current)
|
// Find removed files (in existing but not in current)
|
||||||
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||||
val totalFiles = currentUris.size
|
val totalFiles = currentUris.size
|
||||||
val skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0)
|
val filesToProcess = audioFiles.size + cueFilesToScan.size
|
||||||
|
val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0)
|
||||||
|
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.totalFiles = totalFiles
|
it.totalFiles = totalFiles
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioFiles.isEmpty()) {
|
if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) {
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.isComplete = true
|
it.isComplete = true
|
||||||
it.scannedFiles = totalFiles
|
it.scannedFiles = totalFiles
|
||||||
@@ -1107,6 +1364,173 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = traversalErrors
|
var errors = traversalErrors
|
||||||
|
|
||||||
|
// --- CUE first pass: parse new/modified CUE sheets ---
|
||||||
|
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
|
||||||
|
if (safScanCancel) {
|
||||||
|
updateSafScanProgress { it.isComplete = true }
|
||||||
|
val result = JSONObject()
|
||||||
|
result.put("files", JSONArray())
|
||||||
|
result.put("removedUris", JSONArray())
|
||||||
|
result.put("skippedCount", skippedCount)
|
||||||
|
result.put("totalFiles", totalFiles)
|
||||||
|
result.put("cancelled", true)
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
|
||||||
|
updateSafScanProgress { it.currentFile = cueName }
|
||||||
|
|
||||||
|
var tempCuePath: String? = null
|
||||||
|
var tempAudioPath: String? = null
|
||||||
|
try {
|
||||||
|
// Copy CUE to temp
|
||||||
|
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||||
|
if (tempCuePath == null) {
|
||||||
|
errors++
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}")
|
||||||
|
scanned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the audio filename from the CUE sheet text
|
||||||
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
|
// Find the referenced audio file as a sibling in the same SAF directory
|
||||||
|
var audioDoc: DocumentFile? = null
|
||||||
|
if (!audioFileName.isNullOrBlank()) {
|
||||||
|
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try common audio extensions with the CUE base name
|
||||||
|
if (audioDoc == null) {
|
||||||
|
val cueBaseName = cueName.substringBeforeLast('.')
|
||||||
|
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
||||||
|
for (ext in commonExts) {
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioDoc == null) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
|
||||||
|
errors++
|
||||||
|
scanned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark this audio file so we skip it in the regular audio pass
|
||||||
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
|
|
||||||
|
// Copy audio to same temp dir so Go can resolve it
|
||||||
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
|
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||||
|
|
||||||
|
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
||||||
|
if (tempAudioPath == null) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName")
|
||||||
|
errors++
|
||||||
|
scanned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename temp audio to its original name so Go can find it by name
|
||||||
|
val renamedAudio = File(tempDir, audioName)
|
||||||
|
val tempAudioFile = File(tempAudioPath)
|
||||||
|
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||||
|
tempAudioFile.renameTo(renamedAudio)
|
||||||
|
tempAudioPath = renamedAudio.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Go to produce library scan entries for each CUE track
|
||||||
|
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||||
|
tempCuePath,
|
||||||
|
tempDir,
|
||||||
|
cueDoc.uri.toString(),
|
||||||
|
cueLastModified
|
||||||
|
)
|
||||||
|
|
||||||
|
val cueArray = JSONArray(cueResultsJson)
|
||||||
|
for (j in 0 until cueArray.length()) {
|
||||||
|
val trackObj = cueArray.getJSONObject(j)
|
||||||
|
results.put(trackObj)
|
||||||
|
// Register each virtual path as current so deletion detection works
|
||||||
|
val virtualPath = trackObj.optString("filePath", "")
|
||||||
|
if (virtualPath.isNotBlank()) {
|
||||||
|
currentUris.add(virtualPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errors++
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
|
||||||
|
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned++
|
||||||
|
val processed = skippedCount + scanned
|
||||||
|
val pct = if (totalFiles > 0) {
|
||||||
|
processed.toDouble() / totalFiles.toDouble() * 100.0
|
||||||
|
} else {
|
||||||
|
100.0
|
||||||
|
}
|
||||||
|
updateSafScanProgress {
|
||||||
|
it.scannedFiles = processed
|
||||||
|
it.errorCount = errors
|
||||||
|
it.progressPct = pct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover audio siblings for unchanged CUE files so we skip them
|
||||||
|
// in the regular audio pass. Copy the .cue to temp (tiny file) to extract
|
||||||
|
// the audio filename, then find the sibling by name.
|
||||||
|
for ((cueDoc, parentDir) in unchangedCueFiles) {
|
||||||
|
var tempCue: String? = null
|
||||||
|
try {
|
||||||
|
tempCue = copyUriToTemp(cueDoc.uri, ".cue")
|
||||||
|
if (tempCue != null) {
|
||||||
|
val audioFileName = extractCueAudioFileName(tempCue)
|
||||||
|
var audioDoc: DocumentFile? = null
|
||||||
|
if (!audioFileName.isNullOrBlank()) {
|
||||||
|
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
// Fallback: try common extensions with CUE base name
|
||||||
|
if (audioDoc == null) {
|
||||||
|
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
|
||||||
|
val cueBaseName = cueName.substringBeforeLast('.')
|
||||||
|
if (cueBaseName.isNotBlank()) {
|
||||||
|
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
||||||
|
for (ext in commonExts) {
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audioDoc != null) {
|
||||||
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { tempCue?.let { File(it).delete() } } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
||||||
for ((doc, _, lastModified) in audioFiles) {
|
for ((doc, _, lastModified) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
@@ -1119,6 +1543,22 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip audio files that are represented by CUE track entries
|
||||||
|
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||||
|
scanned++
|
||||||
|
val processed = skippedCount + scanned
|
||||||
|
val pct = if (totalFiles > 0) {
|
||||||
|
processed.toDouble() / totalFiles.toDouble() * 100.0
|
||||||
|
} else {
|
||||||
|
100.0
|
||||||
|
}
|
||||||
|
updateSafScanProgress {
|
||||||
|
it.scannedFiles = processed
|
||||||
|
it.progressPct = pct
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.currentFile = name
|
it.currentFile = name
|
||||||
@@ -1173,6 +1613,9 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recalculate removedUris now that CUE virtual paths have been registered
|
||||||
|
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||||
|
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.isComplete = true
|
it.isComplete = true
|
||||||
it.progressPct = 100.0
|
it.progressPct = 100.0
|
||||||
@@ -1180,7 +1623,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val result = JSONObject()
|
val result = JSONObject()
|
||||||
result.put("files", results)
|
result.put("files", results)
|
||||||
result.put("removedUris", JSONArray(removedUris))
|
result.put("removedUris", JSONArray(finalRemovedUris))
|
||||||
result.put("skippedCount", skippedCount)
|
result.put("skippedCount", skippedCount)
|
||||||
result.put("totalFiles", totalFiles)
|
result.put("totalFiles", totalFiles)
|
||||||
return result.toString()
|
return result.toString()
|
||||||
@@ -1434,38 +1877,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"getSpotifyMetadata" -> {
|
|
||||||
val url = call.argument<String>("url") ?: ""
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.getSpotifyMetadata(url)
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"searchSpotify" -> {
|
|
||||||
val query = call.argument<String>("query") ?: ""
|
|
||||||
val limit = call.argument<Int>("limit") ?: 10
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.searchSpotify(query, limit.toLong())
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"searchSpotifyAll" -> {
|
|
||||||
val query = call.argument<String>("query") ?: ""
|
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
|
||||||
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"getSpotifyRelatedArtists" -> {
|
|
||||||
val artistId = call.argument<String>("artist_id") ?: ""
|
|
||||||
val limit = call.argument<Int>("limit") ?: 12
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"checkAvailability" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -2099,20 +2510,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"isDownloadServiceRunning" -> {
|
"isDownloadServiceRunning" -> {
|
||||||
result.success(DownloadService.isServiceRunning())
|
result.success(DownloadService.isServiceRunning())
|
||||||
}
|
}
|
||||||
"setSpotifyCredentials" -> {
|
|
||||||
val clientId = call.argument<String>("client_id") ?: ""
|
|
||||||
val clientSecret = call.argument<String>("client_secret") ?: ""
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
|
|
||||||
}
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
"hasSpotifyCredentials" -> {
|
|
||||||
val hasCredentials = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.checkSpotifyCredentials()
|
|
||||||
}
|
|
||||||
result.success(hasCredentials)
|
|
||||||
}
|
|
||||||
"preWarmTrackCache" -> {
|
"preWarmTrackCache" -> {
|
||||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -2239,13 +2636,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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) {
|
||||||
@@ -2742,6 +3132,89 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
// CUE Sheet Parsing
|
||||||
|
"parseCueSheet" -> {
|
||||||
|
val cuePath = call.argument<String>("cue_path") ?: ""
|
||||||
|
val audioDir = call.argument<String>("audio_dir") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (cuePath.startsWith("content://")) {
|
||||||
|
val uri = Uri.parse(cuePath)
|
||||||
|
val tempCuePath = copyUriToTemp(uri, ".cue")
|
||||||
|
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
|
||||||
|
var tempAudioPath: String? = null
|
||||||
|
try {
|
||||||
|
// Extract audio filename from CUE text
|
||||||
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
|
// Try to find the audio sibling in SAF
|
||||||
|
var audioDoc: DocumentFile? = null
|
||||||
|
val parentDir = safParentDir(uri)
|
||||||
|
if (parentDir != null && !audioFileName.isNullOrBlank()) {
|
||||||
|
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try common extensions with the CUE base name
|
||||||
|
if (audioDoc == null && parentDir != null) {
|
||||||
|
val cueName = try {
|
||||||
|
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
|
||||||
|
} catch (_: Exception) { "" }
|
||||||
|
val cueBaseName = cueName.substringBeforeLast('.')
|
||||||
|
if (cueBaseName.isNotBlank()) {
|
||||||
|
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
||||||
|
for (ext in commonExts) {
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
||||||
|
if (audioDoc != null) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
|
if (audioDoc != null) {
|
||||||
|
// Copy audio to same temp dir with original name
|
||||||
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
|
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||||
|
val copiedAudio = copyUriToTemp(audioDoc.uri, fallbackExt)
|
||||||
|
if (copiedAudio != null) {
|
||||||
|
val renamedAudio = File(tempDir, audioName)
|
||||||
|
val copiedFile = File(copiedAudio)
|
||||||
|
if (renamedAudio.absolutePath != copiedFile.absolutePath) {
|
||||||
|
copiedFile.renameTo(renamedAudio)
|
||||||
|
}
|
||||||
|
tempAudioPath = renamedAudio.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse with audio in temp dir; Go will resolve there
|
||||||
|
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
|
||||||
|
|
||||||
|
// Replace the temp audio_path with the SAF content:// URI
|
||||||
|
// so Dart knows it's a SAF file and handles it accordingly
|
||||||
|
if (audioDoc != null) {
|
||||||
|
val resultObj = JSONObject(resultJson)
|
||||||
|
resultObj.put("audio_path", audioDoc.uri.toString())
|
||||||
|
// Also pass the original CUE URI for reference
|
||||||
|
resultObj.put("cue_path", cuePath)
|
||||||
|
resultObj.toString()
|
||||||
|
} else {
|
||||||
|
resultJson
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { File(tempCuePath).delete() } catch (_: Exception) {}
|
||||||
|
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Gobackend.parseCueSheet(cuePath, audioDir)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# git-cliff configuration for SpotiFLAC Mobile
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# Template for the changelog body
|
||||||
|
body = """
|
||||||
|
{%- macro remote_url() -%}
|
||||||
|
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{% if version %}\
|
||||||
|
## {{ version | trim_start_matches(pat="v") }}
|
||||||
|
{% else %}\
|
||||||
|
## Unreleased
|
||||||
|
{% endif %}\
|
||||||
|
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||||
|
{{ commit.message | upper_first }}\
|
||||||
|
{% if commit.github.pr_number %} \
|
||||||
|
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||||
|
{% endif %}\
|
||||||
|
{%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||||
|
* @{{ contributor.username }} made their first contribution
|
||||||
|
{%- if contributor.pr_number %} in \
|
||||||
|
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{% if version %}
|
||||||
|
{% if previous.version %}
|
||||||
|
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
|
||||||
|
{% endif %}
|
||||||
|
{% else -%}
|
||||||
|
{% raw %}\n{% endraw %}
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
# Remove leading and trailing whitespace
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# Parse conventional commits
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
|
||||||
|
# Process each line of a commit as an individual commit
|
||||||
|
split_commits = false
|
||||||
|
|
||||||
|
# Regex for preprocessing the commit messages
|
||||||
|
commit_preprocessors = [
|
||||||
|
# Remove PR number from message (we add it back via GitHub integration)
|
||||||
|
{ pattern = '\(#(\d+)\)', replace = '' },
|
||||||
|
# Strip conventional commit prefix for cleaner messages
|
||||||
|
# (group header already shows the type)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Regex for parsing and grouping commits
|
||||||
|
commit_parsers = [
|
||||||
|
# Skip noise: translation commits from Crowdin
|
||||||
|
{ message = "^New translations", skip = true },
|
||||||
|
{ message = "^Update source file", skip = true },
|
||||||
|
# Skip merge commits
|
||||||
|
{ message = "^Merge", skip = true },
|
||||||
|
# Skip version bump commits
|
||||||
|
{ message = "^v\\d+", skip = true },
|
||||||
|
{ message = "^chore: update VirusTotal", skip = true },
|
||||||
|
|
||||||
|
# Group by conventional commit type
|
||||||
|
{ message = "^feat", group = "<!-- 0 -->New Features" },
|
||||||
|
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||||
|
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||||
|
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||||
|
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||||
|
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||||
|
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||||
|
{ message = "^chore\\(l10n\\)", skip = true },
|
||||||
|
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Protect breaking changes from being skipped
|
||||||
|
protect_breaking_commits = true
|
||||||
|
|
||||||
|
# Filter out commits by matching patterns
|
||||||
|
filter_commits = false
|
||||||
|
|
||||||
|
# Tag pattern for version detection
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
|
||||||
|
# Sort commits by newest first
|
||||||
|
sort_commits = "newest"
|
||||||
|
|
||||||
|
[remote.github]
|
||||||
|
owner = "zarzet"
|
||||||
|
repo = "SpotiFLAC-Mobile"
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Amazon API timeout and retry configuration for mobile networks
|
|
||||||
const (
|
|
||||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
|
||||||
amazonMaxRetries = 2 // Number of retry attempts
|
|
||||||
amazonRetryDelay = 500 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmazonDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
|
||||||
amazonDownloaderOnce sync.Once
|
|
||||||
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
|
||||||
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
|
||||||
type AfkarXYZResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
DirectLink string `json:"direct_link"`
|
|
||||||
FileName string `json:"file_name"`
|
|
||||||
FileSize int64 `json:"file_size"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin}
|
|
||||||
type AmazonStreamResponse struct {
|
|
||||||
StreamURL string `json:"streamUrl"`
|
|
||||||
DecryptionKey string `json:"decryptionKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
|
||||||
amazonDownloaderOnce.Do(func() {
|
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return globalAmazonDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
|
||||||
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
|
||||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
|
||||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
|
||||||
time.Sleep(delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
|
||||||
if err == nil {
|
|
||||||
return downloadURL, fileName, decryptionKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = err
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
|
||||||
strings.Contains(errStr, "connection reset") ||
|
|
||||||
strings.Contains(errStr, "connection refused") ||
|
|
||||||
strings.Contains(errStr, "eof") ||
|
|
||||||
strings.Contains(errStr, "status 5") ||
|
|
||||||
strings.Contains(errStr, "status 429") ||
|
|
||||||
strings.Contains(errStr, "http 429")
|
|
||||||
|
|
||||||
if !isRetryable {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeAmazonASIN(candidate string) string {
|
|
||||||
trimmed := strings.TrimSpace(candidate)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
|
||||||
trimmed = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmed = strings.ToUpper(trimmed)
|
|
||||||
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
|
||||||
trimmed = trimmed[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonASINRegex.MatchString(trimmed) {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractAmazonASIN(amazonURL string) string {
|
|
||||||
raw := strings.TrimSpace(amazonURL)
|
|
||||||
if raw == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err == nil {
|
|
||||||
query := parsed.Query()
|
|
||||||
|
|
||||||
// Prefer track-level ASIN when URL also contains albumAsin.
|
|
||||||
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
|
||||||
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := strings.Trim(parsed.Path, "/")
|
|
||||||
if path != "" {
|
|
||||||
segments := strings.Split(path, "/")
|
|
||||||
|
|
||||||
for i := 0; i < len(segments)-1; i++ {
|
|
||||||
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
|
||||||
if segment == "track" || segment == "tracks" {
|
|
||||||
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
|
||||||
return normalizeAmazonASIN(match)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doAfkarXYZRequest performs a single request to Amazon API.
|
|
||||||
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
|
||||||
asin := extractAmazonASIN(amazonURL)
|
|
||||||
if asin != "" {
|
|
||||||
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
|
||||||
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
|
||||||
if err == nil {
|
|
||||||
return downloadURL, fileName, decryptKey, nil
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
|
||||||
}
|
|
||||||
return a.doAfkarXYZRequestLegacy(amazonURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
|
||||||
if readErr != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to read response: %w", readErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp AmazonStreamResponse
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
|
||||||
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := asin + ".m4a"
|
|
||||||
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp AfkarXYZResponse
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
|
||||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := apiResp.Data.FileName
|
|
||||||
if fileName == "" {
|
|
||||||
fileName = "track.flac"
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
|
||||||
|
|
||||||
return apiResp.Data.DirectLink, fileName, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
|
||||||
|
|
||||||
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if decryptionKey != "" {
|
|
||||||
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
|
||||||
return downloadURL, fileName, decryptionKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(pw, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
|
||||||
type AmazonDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
BitDepth int
|
|
||||||
SampleRate int
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
LyricsLRC string
|
|
||||||
DecryptionKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) {
|
|
||||||
if strings.TrimSpace(logPrefix) == "" {
|
|
||||||
logPrefix = "Amazon"
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonURL := ""
|
|
||||||
if req.ISRC != "" {
|
|
||||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
|
||||||
amazonURL = cached.AmazonURL
|
|
||||||
GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonURL != "" {
|
|
||||||
return amazonURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
var availability *TrackAvailability
|
|
||||||
var err error
|
|
||||||
|
|
||||||
deezerID := strings.TrimSpace(req.DeezerID)
|
|
||||||
if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" {
|
|
||||||
deezerID = strings.TrimSpace(prefixedDeezerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerID != "" {
|
|
||||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
|
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
|
||||||
} else if req.SpotifyID != "" {
|
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
|
||||||
} else {
|
|
||||||
return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if availability == nil || !availability.Amazon || availability.AmazonURL == "" {
|
|
||||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonURL = availability.AmazonURL
|
|
||||||
if req.ISRC != "" {
|
|
||||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return amazonURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|
||||||
downloader := NewAmazonDownloader()
|
|
||||||
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
if !isSafOutput {
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonURL, err := resolveAmazonURLForRequest(req, "Amazon")
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSafOutput && req.OutputDir != "." {
|
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download using AfkarXYZ API
|
|
||||||
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
var outputPath string
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
|
||||||
if outputExt == "" {
|
|
||||||
outputExt = ".flac"
|
|
||||||
}
|
|
||||||
filename = sanitizeFilename(filename) + outputExt
|
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
parallelDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(parallelDone)
|
|
||||||
coverURL := req.CoverURL
|
|
||||||
embedLyrics := req.EmbedLyrics
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
coverURL = ""
|
|
||||||
embedLyrics = false
|
|
||||||
}
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
coverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
embedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
|
||||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
actualOutputPath := outputPath
|
|
||||||
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
|
||||||
if needsDecryption {
|
|
||||||
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
|
||||||
<-parallelDone
|
|
||||||
|
|
||||||
if req.ItemID != "" {
|
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
||||||
SetItemFinalizing(req.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
actualTrackNum := req.TrackNumber
|
|
||||||
actualDiscNum := req.DiscNumber
|
|
||||||
actualDate := req.ReleaseDate
|
|
||||||
actualAlbum := req.AlbumName
|
|
||||||
actualTitle := req.TrackName
|
|
||||||
actualArtist := req.ArtistName
|
|
||||||
|
|
||||||
if !needsDecryption {
|
|
||||||
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
|
||||||
actualDiscNum = existingMeta.DiscNumber
|
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
|
||||||
actualDate = existingMeta.Date
|
|
||||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
|
||||||
}
|
|
||||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
|
||||||
actualAlbum = existingMeta.Album
|
|
||||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
|
||||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: actualTitle,
|
|
||||||
Artist: actualArtist,
|
|
||||||
Album: actualAlbum,
|
|
||||||
AlbumArtist: req.AlbumArtist,
|
|
||||||
Date: actualDate,
|
|
||||||
TrackNumber: actualTrackNum,
|
|
||||||
TotalTracks: req.TotalTracks,
|
|
||||||
DiscNumber: actualDiscNum,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Genre: req.Genre,
|
|
||||||
Label: req.Label,
|
|
||||||
Copyright: req.Copyright,
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
|
||||||
if coverErr == nil && len(existingCover) > 0 {
|
|
||||||
coverData = existingCover
|
|
||||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSafOutput || needsDecryption || !req.EmbedMetadata {
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
|
||||||
if isFlacOutput {
|
|
||||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsMode := req.LyricsMode
|
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
|
||||||
}
|
|
||||||
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
|
||||||
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
|
||||||
}
|
|
||||||
} else if req.EmbedLyrics {
|
|
||||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
|
||||||
|
|
||||||
quality := AudioQuality{}
|
|
||||||
if isSafOutput || needsDecryption {
|
|
||||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
|
||||||
} else {
|
|
||||||
quality, err = GetAudioQuality(actualOutputPath)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
|
||||||
actualTrackNum = finalMeta.TrackNumber
|
|
||||||
actualDiscNum = finalMeta.DiscNumber
|
|
||||||
if finalMeta.Date != "" {
|
|
||||||
req.ReleaseDate = finalMeta.Date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking.
|
|
||||||
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
|
||||||
if !isSafOutput && !needsDecryption {
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
bitDepth := 0
|
|
||||||
sampleRate := 0
|
|
||||||
if err == nil {
|
|
||||||
bitDepth = quality.BitDepth
|
|
||||||
sampleRate = quality.SampleRate
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
}
|
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: bitDepth,
|
|
||||||
SampleRate: sampleRate,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: actualTrackNum,
|
|
||||||
DiscNumber: actualDiscNum,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
DecryptionKey: decryptionKey,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestExtractAmazonASIN(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "prefers trackAsin over albumAsin",
|
|
||||||
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
|
||||||
want: "B0TRACK456",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extract from tracks path",
|
|
||||||
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extract from plain query asin",
|
|
||||||
url: "https://example.com/?asin=B0CYQHGWZJ",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fallback regex",
|
|
||||||
url: "https://example.com/path/B0CYQHGWZJ",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid url",
|
|
||||||
url: "https://music.amazon.com/tracks/not-valid",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := extractAmazonASIN(tt.url)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AudioMetadata represents common audio file metadata
|
|
||||||
type AudioMetadata struct {
|
type AudioMetadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -31,7 +30,6 @@ type AudioMetadata struct {
|
|||||||
Comment string
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP3Quality represents MP3 specific quality info
|
|
||||||
type MP3Quality struct {
|
type MP3Quality struct {
|
||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -39,7 +37,6 @@ type MP3Quality struct {
|
|||||||
Bitrate int
|
Bitrate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// OggQuality represents Ogg/Opus specific quality info
|
|
||||||
type OggQuality struct {
|
type OggQuality struct {
|
||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -47,10 +44,6 @@ type OggQuality struct {
|
|||||||
Bitrate int // estimated bitrate in bps
|
Bitrate int // estimated bitrate in bps
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ID3 Tag Reading (MP3)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1210,10 +1203,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ID3v1 Genre List
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
var id3v1Genres = []string{
|
var id3v1Genres = []string{
|
||||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||||
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||||
@@ -1244,10 +1233,6 @@ var id3v1Genres = []string{
|
|||||||
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Cover Art Extraction
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CueSheet represents a parsed .cue file
|
||||||
|
type CueSheet struct {
|
||||||
|
// Album-level metadata
|
||||||
|
Performer string `json:"performer"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
Tracks []CueTrack `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CueTrack represents a single track in a cue sheet
|
||||||
|
type CueTrack struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Performer string `json:"performer"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
// Index positions in seconds (fractional)
|
||||||
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
|
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CueSplitInfo represents the information needed to split a CUE+audio file
|
||||||
|
type CueSplitInfo struct {
|
||||||
|
CuePath string `json:"cue_path"`
|
||||||
|
AudioPath string `json:"audio_path"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Tracks []CueSplitTrack `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CueSplitTrack has the FFmpeg split parameters for a single track
|
||||||
|
type CueSplitTrack struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
StartSec float64 `json:"start_sec"`
|
||||||
|
EndSec float64 `json:"end_sec"` // -1 means until end of file
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
|
||||||
|
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCueFile parses a .cue file and returns a CueSheet
|
||||||
|
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||||
|
f, err := os.Open(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open cue file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
sheet := &CueSheet{}
|
||||||
|
var currentTrack *CueTrack
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle BOM at start of file
|
||||||
|
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||||
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
upper := strings.ToUpper(line)
|
||||||
|
|
||||||
|
// REM commands (album-level metadata)
|
||||||
|
if strings.HasPrefix(upper, "REM ") {
|
||||||
|
matches := reRemCommand.FindStringSubmatch(line)
|
||||||
|
if len(matches) == 3 {
|
||||||
|
key := strings.ToUpper(matches[1])
|
||||||
|
value := unquoteCue(matches[2])
|
||||||
|
switch key {
|
||||||
|
case "GENRE":
|
||||||
|
sheet.Genre = value
|
||||||
|
case "DATE":
|
||||||
|
sheet.Date = value
|
||||||
|
case "COMMENT":
|
||||||
|
sheet.Comment = value
|
||||||
|
case "COMPOSER":
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Composer = value
|
||||||
|
} else {
|
||||||
|
sheet.Composer = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// PERFORMER
|
||||||
|
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||||
|
value := unquoteCue(line[len("PERFORMER "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Performer = value
|
||||||
|
} else {
|
||||||
|
sheet.Performer = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TITLE
|
||||||
|
if strings.HasPrefix(upper, "TITLE ") {
|
||||||
|
value := unquoteCue(line[len("TITLE "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Title = value
|
||||||
|
} else {
|
||||||
|
sheet.Title = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILE
|
||||||
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
|
rest := line[len("FILE "):]
|
||||||
|
// Extract filename and type
|
||||||
|
// Format: FILE "filename.flac" WAVE
|
||||||
|
// or: FILE filename.flac WAVE
|
||||||
|
fname, ftype := parseCueFileLine(rest)
|
||||||
|
sheet.FileName = fname
|
||||||
|
sheet.FileType = ftype
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACK
|
||||||
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
|
// Save previous track
|
||||||
|
if currentTrack != nil {
|
||||||
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
trackNum := 0
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
trackNum, _ = strconv.Atoi(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrack = &CueTrack{
|
||||||
|
Number: trackNum,
|
||||||
|
PreGap: -1,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// INDEX
|
||||||
|
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
indexNum, _ := strconv.Atoi(parts[1])
|
||||||
|
timeSec := parseCueTimestamp(parts[2])
|
||||||
|
switch indexNum {
|
||||||
|
case 0:
|
||||||
|
currentTrack.PreGap = timeSec
|
||||||
|
case 1:
|
||||||
|
currentTrack.StartTime = timeSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC
|
||||||
|
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||||
|
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// SONGWRITER (used as composer sometimes)
|
||||||
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Composer = value
|
||||||
|
} else {
|
||||||
|
sheet.Composer = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't forget the last track
|
||||||
|
if currentTrack != nil {
|
||||||
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading cue file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sheet.Tracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found in cue file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sheet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
|
||||||
|
func parseCueTimestamp(ts string) float64 {
|
||||||
|
parts := strings.Split(ts, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes, _ := strconv.Atoi(parts[0])
|
||||||
|
seconds, _ := strconv.Atoi(parts[1])
|
||||||
|
frames, _ := strconv.Atoi(parts[2])
|
||||||
|
|
||||||
|
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
|
||||||
|
func formatCueTimestamp(seconds float64) string {
|
||||||
|
if seconds < 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
hours := int(seconds) / 3600
|
||||||
|
mins := (int(seconds) % 3600) / 60
|
||||||
|
secs := seconds - float64(hours*3600) - float64(mins*60)
|
||||||
|
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unquoteCue removes surrounding quotes from a CUE value
|
||||||
|
func unquoteCue(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCueFileLine parses the FILE command's filename and type
|
||||||
|
func parseCueFileLine(rest string) (string, string) {
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
|
||||||
|
var filename, ftype string
|
||||||
|
|
||||||
|
if strings.HasPrefix(rest, "\"") {
|
||||||
|
// Quoted filename
|
||||||
|
endQuote := strings.Index(rest[1:], "\"")
|
||||||
|
if endQuote >= 0 {
|
||||||
|
filename = rest[1 : endQuote+1]
|
||||||
|
remaining := strings.TrimSpace(rest[endQuote+2:])
|
||||||
|
ftype = remaining
|
||||||
|
} else {
|
||||||
|
filename = rest
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unquoted filename - last word is the type
|
||||||
|
parts := strings.Fields(rest)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
ftype = parts[len(parts)-1]
|
||||||
|
filename = strings.Join(parts[:len(parts)-1], " ")
|
||||||
|
} else if len(parts) == 1 {
|
||||||
|
filename = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, strings.TrimSpace(ftype)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
|
||||||
|
// It checks relative to the cue file's directory.
|
||||||
|
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||||
|
cueDir := filepath.Dir(cuePath)
|
||||||
|
|
||||||
|
// 1. Try the exact filename from the .cue
|
||||||
|
candidate := filepath.Join(cueDir, cueFileName)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try common case variations
|
||||||
|
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||||
|
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||||
|
for _, ext := range commonExts {
|
||||||
|
candidate = filepath.Join(cueDir, baseName+ext)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
// Try uppercase ext
|
||||||
|
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try to find any audio file with the same base name as the .cue file
|
||||||
|
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||||
|
for _, ext := range commonExts {
|
||||||
|
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If there's only one audio file in the directory, use that
|
||||||
|
entries, err := os.ReadDir(cueDir)
|
||||||
|
if err == nil {
|
||||||
|
audioExts := map[string]bool{
|
||||||
|
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
|
||||||
|
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
|
||||||
|
}
|
||||||
|
var audioFiles []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||||
|
if audioExts[ext] {
|
||||||
|
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(audioFiles) == 1 {
|
||||||
|
return audioFiles[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
|
||||||
|
// This is returned to the Dart side so FFmpeg can perform the splitting.
|
||||||
|
// audioDir, if non-empty, overrides the directory for audio file resolution.
|
||||||
|
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||||
|
resolveDir := cuePath
|
||||||
|
if audioDir != "" {
|
||||||
|
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
|
||||||
|
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
|
}
|
||||||
|
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||||
|
if audioPath == "" {
|
||||||
|
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &CueSplitInfo{
|
||||||
|
CuePath: cuePath,
|
||||||
|
AudioPath: audioPath,
|
||||||
|
Album: sheet.Title,
|
||||||
|
Artist: sheet.Performer,
|
||||||
|
Genre: sheet.Genre,
|
||||||
|
Date: sheet.Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, track := range sheet.Tracks {
|
||||||
|
performer := track.Performer
|
||||||
|
if performer == "" {
|
||||||
|
performer = sheet.Performer
|
||||||
|
}
|
||||||
|
|
||||||
|
composer := track.Composer
|
||||||
|
if composer == "" {
|
||||||
|
composer = sheet.Composer
|
||||||
|
}
|
||||||
|
|
||||||
|
// End time is the start of the next track, or -1 for the last track
|
||||||
|
endSec := float64(-1)
|
||||||
|
if i+1 < len(sheet.Tracks) {
|
||||||
|
nextTrack := sheet.Tracks[i+1]
|
||||||
|
// Use pre-gap of next track if available, otherwise its start time
|
||||||
|
if nextTrack.PreGap >= 0 {
|
||||||
|
endSec = nextTrack.PreGap
|
||||||
|
} else {
|
||||||
|
endSec = nextTrack.StartTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Tracks = append(info.Tracks, CueSplitTrack{
|
||||||
|
Number: track.Number,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: performer,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Composer: composer,
|
||||||
|
StartSec: track.StartTime,
|
||||||
|
EndSec: endSec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
|
||||||
|
// This is the main entry point called from Dart via the platform bridge.
|
||||||
|
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||||
|
// referenced audio file (useful when the .cue was copied to a temp dir
|
||||||
|
// but the audio still lives in the original location, e.g. SAF).
|
||||||
|
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse cue file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
|
||||||
|
// entries, one per track. This is used by the library scanner to populate the
|
||||||
|
// library with individual track entries from a single CUE+FLAC album.
|
||||||
|
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||||
|
// for SAF (Storage Access Framework) scenarios:
|
||||||
|
// - audioDir: if non-empty, overrides the directory used to find the audio file
|
||||||
|
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
|
||||||
|
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
|
||||||
|
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
||||||
|
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
||||||
|
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve audio file — optionally in an overridden directory
|
||||||
|
resolveBase := cuePath
|
||||||
|
if audioDir != "" {
|
||||||
|
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
|
}
|
||||||
|
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||||
|
if audioPath == "" {
|
||||||
|
return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get quality info from the audio file
|
||||||
|
var bitDepth, sampleRate int
|
||||||
|
var totalDurationSec float64
|
||||||
|
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||||
|
switch audioExt {
|
||||||
|
case ".flac":
|
||||||
|
quality, qErr := GetAudioQuality(audioPath)
|
||||||
|
if qErr == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ".mp3":
|
||||||
|
quality, qErr := GetMP3Quality(audioPath)
|
||||||
|
if qErr == nil {
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
totalDurationSec = float64(quality.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract cover from audio file for all tracks
|
||||||
|
var coverPath string
|
||||||
|
libraryCoverCacheMu.RLock()
|
||||||
|
coverCacheDir := libraryCoverCacheDir
|
||||||
|
libraryCoverCacheMu.RUnlock()
|
||||||
|
if coverCacheDir != "" {
|
||||||
|
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
|
||||||
|
if err == nil && cp != "" {
|
||||||
|
coverPath = cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the base path for virtual paths and IDs
|
||||||
|
pathBase := cuePath
|
||||||
|
if virtualPathPrefix != "" {
|
||||||
|
pathBase = virtualPathPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine fileModTime
|
||||||
|
modTime := fileModTime
|
||||||
|
if modTime <= 0 {
|
||||||
|
if info, err := os.Stat(cuePath); err == nil {
|
||||||
|
modTime = info.ModTime().UnixMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []LibraryScanResult
|
||||||
|
for i, track := range sheet.Tracks {
|
||||||
|
performer := track.Performer
|
||||||
|
if performer == "" {
|
||||||
|
performer = sheet.Performer
|
||||||
|
}
|
||||||
|
if performer == "" {
|
||||||
|
performer = "Unknown Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
title := track.Title
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("Track %02d", track.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
album := sheet.Title
|
||||||
|
if album == "" {
|
||||||
|
album = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate duration for this track
|
||||||
|
var duration int
|
||||||
|
if i+1 < len(sheet.Tracks) {
|
||||||
|
nextStart := sheet.Tracks[i+1].StartTime
|
||||||
|
if sheet.Tracks[i+1].PreGap >= 0 {
|
||||||
|
nextStart = sheet.Tracks[i+1].PreGap
|
||||||
|
}
|
||||||
|
duration = int(nextStart - track.StartTime)
|
||||||
|
} else if totalDurationSec > 0 {
|
||||||
|
duration = int(totalDurationSec - track.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a unique ID based on pathBase + track number
|
||||||
|
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||||
|
|
||||||
|
// Use a virtual file path that includes the track number to ensure
|
||||||
|
// uniqueness in the database (file_path has a UNIQUE constraint).
|
||||||
|
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
|
||||||
|
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||||
|
|
||||||
|
result := LibraryScanResult{
|
||||||
|
ID: id,
|
||||||
|
TrackName: title,
|
||||||
|
ArtistName: performer,
|
||||||
|
AlbumName: album,
|
||||||
|
AlbumArtist: sheet.Performer,
|
||||||
|
FilePath: virtualFilePath,
|
||||||
|
CoverPath: coverPath,
|
||||||
|
ScannedAt: scanTime,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
TrackNumber: track.Number,
|
||||||
|
DiscNumber: 1,
|
||||||
|
Duration: duration,
|
||||||
|
ReleaseDate: sheet.Date,
|
||||||
|
BitDepth: bitDepth,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
Genre: sheet.Genre,
|
||||||
|
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||||
|
}
|
||||||
|
|
||||||
|
result.FileModTime = modTime
|
||||||
|
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
|
|||||||
req.Header.Set("Accept", "*/*")
|
req.Header.Set("Accept", "*/*")
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := GetDownloadClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
@@ -324,7 +324,7 @@ func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, ou
|
|||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := GetDownloadClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
|
|||||||
+37
-241
@@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
|
||||||
SetSpotifyCredentials(clientID, clientSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckSpotifyCredentials() bool {
|
|
||||||
return HasSpotifyCredentials()
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
if shouldTrySpotFetchFallback(err) {
|
|
||||||
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
|
||||||
if apiErr == nil {
|
|
||||||
jsonBytes, marshalErr := json.Marshal(data)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return "", marshalErr
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
|
||||||
if err != nil {
|
|
||||||
if shouldTrySpotFetchFallback(err) {
|
|
||||||
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
|
||||||
if apiErr == nil {
|
|
||||||
jsonBytes, marshalErr := json.Marshal(fallbackData)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return "", marshalErr
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SearchSpotify(query string, limit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
results, err := client.SearchTracks(ctx, query, limit)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
|
|
||||||
if normalizedArtistID == "" {
|
|
||||||
return "", fmt.Errorf("invalid Spotify artist ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"artists": artists,
|
|
||||||
}
|
|
||||||
jsonBytes, err := json.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
@@ -478,25 +358,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
|
||||||
if amazonErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: amazonResult.FilePath,
|
|
||||||
BitDepth: amazonResult.BitDepth,
|
|
||||||
SampleRate: amazonResult.SampleRate,
|
|
||||||
Title: amazonResult.Title,
|
|
||||||
Artist: amazonResult.Artist,
|
|
||||||
Album: amazonResult.Album,
|
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
|
||||||
ISRC: amazonResult.ISRC,
|
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
|
||||||
DecryptionKey: amazonResult.DecryptionKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = amazonErr
|
|
||||||
case "deezer":
|
case "deezer":
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||||
if deezerErr == nil {
|
if deezerErr == nil {
|
||||||
@@ -640,7 +501,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
enrichRequestExtendedMetadata(&req)
|
enrichRequestExtendedMetadata(&req)
|
||||||
|
|
||||||
allServices := []string{"tidal", "qobuz", "amazon", "deezer"}
|
allServices := []string{"tidal", "qobuz", "deezer"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "tidal"
|
||||||
@@ -707,27 +568,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
|
||||||
if amazonErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: amazonResult.FilePath,
|
|
||||||
BitDepth: amazonResult.BitDepth,
|
|
||||||
SampleRate: amazonResult.SampleRate,
|
|
||||||
Title: amazonResult.Title,
|
|
||||||
Artist: amazonResult.Artist,
|
|
||||||
Album: amazonResult.Album,
|
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
|
||||||
ISRC: amazonResult.ISRC,
|
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
|
||||||
DecryptionKey: amazonResult.DecryptionKey,
|
|
||||||
}
|
|
||||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
|
||||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
|
||||||
}
|
|
||||||
err = amazonErr
|
|
||||||
case "deezer":
|
case "deezer":
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||||
if deezerErr == nil {
|
if deezerErr == nil {
|
||||||
@@ -824,6 +664,7 @@ func CleanupConnections() {
|
|||||||
func ReadFileMetadata(filePath string) (string, error) {
|
func ReadFileMetadata(filePath string) (string, error) {
|
||||||
lower := strings.ToLower(filePath)
|
lower := strings.ToLower(filePath)
|
||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
|
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
|
||||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||||
|
|
||||||
@@ -873,6 +714,12 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if isM4A {
|
||||||
|
quality, qualityErr := GetM4AQuality(filePath)
|
||||||
|
if qualityErr == nil {
|
||||||
|
result["bit_depth"] = quality.BitDepth
|
||||||
|
result["sample_rate"] = quality.SampleRate
|
||||||
|
}
|
||||||
} else if isMp3 {
|
} else if isMp3 {
|
||||||
meta, err := ReadID3Tags(filePath)
|
meta, err := ReadID3Tags(filePath)
|
||||||
if err == nil && meta != nil {
|
if err == nil && meta != nil {
|
||||||
@@ -934,6 +781,32 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseCueSheet parses a .cue file and returns JSON with split information.
|
||||||
|
// This is called from Dart to get track listing and timing data for CUE splitting.
|
||||||
|
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||||
|
// referenced audio file (useful for SAF temp file scenarios).
|
||||||
|
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
|
||||||
|
return ParseCueFileJSON(cuePath, audioDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
|
||||||
|
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
|
||||||
|
// - audioDir overrides where the referenced audio file is resolved
|
||||||
|
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
|
||||||
|
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
|
||||||
|
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// EditFileMetadata writes metadata to an audio file.
|
// EditFileMetadata writes metadata to an audio file.
|
||||||
// For FLAC files, uses native Go FLAC library.
|
// For FLAC files, uses native Go FLAC library.
|
||||||
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
|
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
|
||||||
@@ -1446,28 +1319,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var spotifyErr error
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
|
||||||
spotifyErr = err
|
|
||||||
} else {
|
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
|
||||||
if err == nil {
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
spotifyErr = err
|
|
||||||
if !shouldTrySpotFetchFallback(err) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
if apiErr == nil {
|
if apiErr == nil {
|
||||||
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
||||||
@@ -1481,9 +1332,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
if spotifyErr != nil {
|
|
||||||
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1494,15 +1342,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
if spotifyErr != nil {
|
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
|
||||||
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if spotifyErr != nil {
|
|
||||||
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1579,11 +1421,6 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|||||||
return client.GetTidalURLFromDeezer(deezerTrackID)
|
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
return client.GetAmazonURLFromDeezer(deezerTrackID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
errorType := "unknown"
|
errorType := "unknown"
|
||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
@@ -1838,8 +1675,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||||
|
|
||||||
// When search_online is true, search for metadata from internet
|
// When search_online is true, search for metadata from internet.
|
||||||
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
|
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc)
|
||||||
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
||||||
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
||||||
searchQuery := req.TrackName + " " + req.ArtistName
|
searchQuery := req.TrackName + " " + req.ArtistName
|
||||||
@@ -1913,37 +1750,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Try Spotify built-in API as last resort (will be deprecated)
|
|
||||||
if !found {
|
|
||||||
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
|
|
||||||
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
|
|
||||||
if spotifyErr == nil {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
|
|
||||||
cancel()
|
|
||||||
if err == nil && len(results.Tracks) > 0 {
|
|
||||||
track := results.Tracks[0]
|
|
||||||
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
|
|
||||||
req.SpotifyID = track.SpotifyID
|
|
||||||
req.AlbumName = track.AlbumName
|
|
||||||
req.AlbumArtist = track.AlbumArtist
|
|
||||||
req.TrackNumber = track.TrackNumber
|
|
||||||
req.DiscNumber = track.DiscNumber
|
|
||||||
req.ReleaseDate = track.ReleaseDate
|
|
||||||
req.ISRC = track.ISRC
|
|
||||||
if track.Images != "" {
|
|
||||||
req.CoverURL = track.Images
|
|
||||||
}
|
|
||||||
req.DurationMs = int64(track.DurationMS)
|
|
||||||
found = true
|
|
||||||
} else if err != nil {
|
|
||||||
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get extended metadata (genre, label) from Deezer if not already set
|
// Try to get extended metadata (genre, label) from Deezer if not already set
|
||||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -2146,8 +1952,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION SYSTEM ====================
|
|
||||||
|
|
||||||
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||||
@@ -2519,8 +2323,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
|
||||||
|
|
||||||
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -3273,9 +3075,6 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
|||||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== LOCAL LIBRARY SCANNING ====================
|
|
||||||
|
|
||||||
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
|
|
||||||
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
||||||
SetLibraryCoverCacheDir(cacheDir)
|
SetLibraryCoverCacheDir(cacheDir)
|
||||||
}
|
}
|
||||||
@@ -3284,9 +3083,6 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
|||||||
return ScanLibraryFolder(folderPath)
|
return ScanLibraryFolder(folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLibraryFolderIncrementalJSON performs an incremental library scan
|
|
||||||
// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis)
|
|
||||||
// Returns IncrementalScanResult as JSON
|
|
||||||
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
||||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,7 +401,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
@@ -467,17 +466,11 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally remove data directory (keep for now to preserve settings)
|
|
||||||
// if ext.DataDir != "" {
|
|
||||||
// os.RemoveAll(ext.DataDir)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -529,7 +522,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare versions - only allow upgrade, not downgrade
|
|
||||||
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||||
if versionCompare < 0 {
|
if versionCompare < 0 {
|
||||||
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||||
@@ -540,7 +532,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
|
|
||||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||||
|
|
||||||
// Save data directory path and enabled state (we want to preserve them)
|
|
||||||
extDataDir := existing.DataDir
|
extDataDir := existing.DataDir
|
||||||
extDir := existing.SourceDir
|
extDir := existing.SourceDir
|
||||||
wasEnabled := existing.Enabled
|
wasEnabled := existing.Enabled
|
||||||
@@ -601,7 +592,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Goja VM
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
@@ -626,7 +616,6 @@ type ExtensionUpgradeInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -675,7 +664,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
// Not installed - this is a new install, not upgrade
|
|
||||||
info.CurrentVersion = ""
|
info.CurrentVersion = ""
|
||||||
info.CanUpgrade = false
|
info.CanUpgrade = false
|
||||||
} else {
|
} else {
|
||||||
@@ -739,7 +727,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
permissions = append(permissions, "storage:enabled")
|
permissions = append(permissions, "storage:enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status
|
|
||||||
status := "loaded"
|
status := "loaded"
|
||||||
if ext.Error != "" {
|
if ext.Error != "" {
|
||||||
status = "error"
|
status = "error"
|
||||||
@@ -940,7 +927,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension is disabled")
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the action function on the extension object
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension manifest parsing and validation
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -99,15 +100,16 @@ type ExtDownloadResult struct {
|
|||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
Album string `json:"album,omitempty"`
|
Album string `json:"album,omitempty"`
|
||||||
AlbumArtist string `json:"album_artist,omitempty"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionProviderWrapper struct {
|
type ExtensionProviderWrapper struct {
|
||||||
@@ -388,7 +390,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
return &enrichedTrack, nil
|
return &enrichedTrack, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
|
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -403,11 +405,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
||||||
return extension.checkAvailability(%q, %q, %q);
|
return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, isrc, trackName, artistName)
|
`, isrc, trackName, artistName, spotifyID, deezerID)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -631,7 +633,7 @@ func GetProviderPriority() []string {
|
|||||||
defer providerPriorityMu.RUnlock()
|
defer providerPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(providerPriority) == 0 {
|
if len(providerPriority) == 0 {
|
||||||
return []string{"tidal", "qobuz", "amazon", "deezer"}
|
return []string{"tidal", "qobuz", "deezer"}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]string, len(providerPriority))
|
result := make([]string, len(providerPriority))
|
||||||
@@ -642,8 +644,26 @@ func GetProviderPriority() []string {
|
|||||||
func SetMetadataProviderPriority(providerIDs []string) {
|
func SetMetadataProviderPriority(providerIDs []string) {
|
||||||
metadataProviderPriorityMu.Lock()
|
metadataProviderPriorityMu.Lock()
|
||||||
defer metadataProviderPriorityMu.Unlock()
|
defer metadataProviderPriorityMu.Unlock()
|
||||||
metadataProviderPriority = providerIDs
|
|
||||||
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
|
sanitized := make([]string, 0, len(providerIDs)+1)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if providerID == "" || providerID == "spotify" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[providerID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[providerID] = struct{}{}
|
||||||
|
sanitized = append(sanitized, providerID)
|
||||||
|
}
|
||||||
|
if _, exists := seen["deezer"]; !exists {
|
||||||
|
sanitized = append([]string{"deezer"}, sanitized...)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataProviderPriority = sanitized
|
||||||
|
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMetadataProviderPriority() []string {
|
func GetMetadataProviderPriority() []string {
|
||||||
@@ -651,7 +671,7 @@ func GetMetadataProviderPriority() []string {
|
|||||||
defer metadataProviderPriorityMu.RUnlock()
|
defer metadataProviderPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(metadataProviderPriority) == 0 {
|
if len(metadataProviderPriority) == 0 {
|
||||||
return []string{"deezer", "spotify"}
|
return []string{"deezer"}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]string, len(metadataProviderPriority))
|
result := make([]string, len(metadataProviderPriority))
|
||||||
@@ -661,7 +681,7 @@ func GetMetadataProviderPriority() []string {
|
|||||||
|
|
||||||
func isBuiltInProvider(providerID string) bool {
|
func isBuiltInProvider(providerID string) bool {
|
||||||
switch providerID {
|
switch providerID {
|
||||||
case "tidal", "qobuz", "amazon", "deezer":
|
case "tidal", "qobuz", "deezer":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -694,6 +714,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
priority = newPriority
|
priority = newPriority
|
||||||
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||||
|
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
|
||||||
|
found := false
|
||||||
|
for _, p := range priority {
|
||||||
|
if strings.EqualFold(p, req.Service) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newPriority := []string{req.Service}
|
||||||
|
for _, p := range priority {
|
||||||
|
if !strings.EqualFold(p, req.Service) {
|
||||||
|
newPriority = append(newPriority, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
priority = newPriority
|
||||||
|
if !found {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service)
|
||||||
|
}
|
||||||
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -777,7 +818,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||||
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPathForExtension(req, ext)
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
@@ -813,6 +854,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||||
@@ -966,7 +1008,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
|
||||||
if err != nil || !availability.Available {
|
if err != nil || !availability.Available {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -975,7 +1017,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPathForExtension(req, ext)
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
@@ -1011,6 +1053,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||||
@@ -1128,25 +1171,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
|
||||||
if amazonErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: amazonResult.FilePath,
|
|
||||||
BitDepth: amazonResult.BitDepth,
|
|
||||||
SampleRate: amazonResult.SampleRate,
|
|
||||||
Title: amazonResult.Title,
|
|
||||||
Artist: amazonResult.Artist,
|
|
||||||
Album: amazonResult.Album,
|
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
|
||||||
ISRC: amazonResult.ISRC,
|
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
|
||||||
DecryptionKey: amazonResult.DecryptionKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = amazonErr
|
|
||||||
case "deezer":
|
case "deezer":
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||||
if deezerErr == nil {
|
if deezerErr == nil {
|
||||||
@@ -1226,7 +1250,58 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
ext = "." + ext
|
ext = "." + ext
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
|
outputDir := req.OutputDir
|
||||||
|
if strings.TrimSpace(outputDir) == "" {
|
||||||
|
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
||||||
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
AddAllowedDownloadDir(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(outputDir, filename+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string {
|
||||||
|
if strings.TrimSpace(req.OutputPath) != "" {
|
||||||
|
return strings.TrimSpace(req.OutputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.OutputDir) != "" {
|
||||||
|
return buildOutputPath(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAF mode: use extension's data dir as writable temp location
|
||||||
|
tempDir := filepath.Join(ext.DataDir, "downloads")
|
||||||
|
os.MkdirAll(tempDir, 0755)
|
||||||
|
AddAllowedDownloadDir(tempDir)
|
||||||
|
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"title": req.TrackName,
|
||||||
|
"artist": req.ArtistName,
|
||||||
|
"album": req.AlbumName,
|
||||||
|
"album_artist": req.AlbumArtist,
|
||||||
|
"track": req.TrackNumber,
|
||||||
|
"track_number": req.TrackNumber,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
|
"disc_number": req.DiscNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"release_date": req.ReleaseDate,
|
||||||
|
"isrc": req.ISRC,
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
|
if filename == "" {
|
||||||
|
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||||
|
}
|
||||||
|
|
||||||
|
outputExt := strings.TrimSpace(req.OutputExt)
|
||||||
|
if outputExt == "" {
|
||||||
|
outputExt = ".flac"
|
||||||
|
} else if !strings.HasPrefix(outputExt, ".") {
|
||||||
|
outputExt = "." + outputExt
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(tempDir, filename+outputExt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||||
@@ -1653,7 +1728,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPostProcessingProviders returns all extensions that provide post-processing
|
|
||||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -1667,7 +1741,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunPostProcessing runs all enabled post-processing hooks on a file
|
|
||||||
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||||
providers := m.GetPostProcessingProviders()
|
providers := m.GetPostProcessingProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
@@ -1713,7 +1786,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
|||||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
|
|
||||||
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||||
providers := m.GetPostProcessingProviders()
|
providers := m.GetPostProcessingProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
@@ -1768,9 +1840,6 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
|
|||||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Lyrics Provider ====================
|
|
||||||
|
|
||||||
// ExtLyricsResult represents lyrics data returned from an extension
|
|
||||||
type ExtLyricsResult struct {
|
type ExtLyricsResult struct {
|
||||||
Lines []ExtLyricsLine `json:"lines"`
|
Lines []ExtLyricsLine `json:"lines"`
|
||||||
SyncType string `json:"syncType"`
|
SyncType string `json:"syncType"`
|
||||||
@@ -1785,7 +1854,6 @@ type ExtLyricsLine struct {
|
|||||||
EndTimeMs int64 `json:"endTimeMs"`
|
EndTimeMs int64 `json:"endTimeMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics calls the extension's fetchLyrics function
|
|
||||||
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
if !p.extension.Manifest.IsLyricsProvider() {
|
if !p.extension.Manifest.IsLyricsProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||||
@@ -1885,7 +1953,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsProviders returns all enabled extensions that provide lyrics
|
|
||||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,8 +15,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
|
||||||
|
|
||||||
func validateExtensionAuthURL(urlStr string) error {
|
func validateExtensionAuthURL(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PKCE Support ====================
|
|
||||||
|
|
||||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
|
||||||
// Length should be between 43-128 characters (RFC 7636)
|
// Length should be between 43-128 characters (RFC 7636)
|
||||||
func generatePKCEVerifier(length int) (string, error) {
|
func generatePKCEVerifier(length int) (string, error) {
|
||||||
if length < 43 {
|
if length < 43 {
|
||||||
@@ -394,9 +388,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
|
||||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||||
// Uses the stored PKCE verifier automatically
|
|
||||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -414,7 +406,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required fields
|
|
||||||
tokenURL, _ := config["tokenUrl"].(string)
|
tokenURL, _ := config["tokenUrl"].(string)
|
||||||
clientID, _ := config["clientId"].(string)
|
clientID, _ := config["clientId"].(string)
|
||||||
redirectURI, _ := config["redirectUri"].(string)
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides FFmpeg API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,9 +9,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== FFmpeg API (Post-Processing) ====================
|
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
|
||||||
|
|
||||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
|
||||||
type FFmpegCommand struct {
|
type FFmpegCommand struct {
|
||||||
ExtensionID string
|
ExtensionID string
|
||||||
Command string
|
Command string
|
||||||
@@ -24,7 +21,6 @@ type FFmpegCommand struct {
|
|||||||
Output string
|
Output string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global FFmpeg command queue
|
|
||||||
var (
|
var (
|
||||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||||
ffmpegCommandsMu sync.RWMutex
|
ffmpegCommandsMu sync.RWMutex
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides File API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,8 +12,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== File API (Sandboxed) ====================
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
allowedDownloadDirs []string
|
allowedDownloadDirs []string
|
||||||
allowedDownloadDirsMu sync.RWMutex
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides HTTP API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,8 +11,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== HTTP API (Sandboxed) ====================
|
|
||||||
|
|
||||||
type HTTPResponse struct {
|
type HTTPResponse struct {
|
||||||
StatusCode int `json:"statusCode"`
|
StatusCode int `json:"statusCode"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Track Matching API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,8 +6,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Track Matching API ====================
|
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(0.0)
|
return r.vm.ToValue(0.0)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,12 +12,10 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Browser-like Polyfills ====================
|
|
||||||
// These polyfills make porting browser/Node.js libraries easier
|
// These polyfills make porting browser/Node.js libraries easier
|
||||||
// without compromising sandbox security
|
// without compromising sandbox security.
|
||||||
|
|
||||||
// fetchPolyfill implements browser-compatible fetch() API
|
// Returns a Promise-like object with json(), text() methods.
|
||||||
// Returns a Promise-like object with json(), text() methods
|
|
||||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.createFetchError("URL is required")
|
return r.createFetchError("URL is required")
|
||||||
@@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return responseObj
|
return responseObj
|
||||||
}
|
}
|
||||||
|
|
||||||
// createFetchError creates a fetch error response
|
|
||||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||||
errorObj := r.vm.NewObject()
|
errorObj := r.vm.NewObject()
|
||||||
errorObj.Set("ok", false)
|
errorObj.Set("ok", false)
|
||||||
@@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
|||||||
return errorObj
|
return errorObj
|
||||||
}
|
}
|
||||||
|
|
||||||
// atobPolyfill implements browser atob() - decode base64 to string
|
|
||||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
// btoaPolyfill implements browser btoa() - encode string to base64
|
|
||||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -183,7 +177,6 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
|
||||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
encoder := call.This
|
encoder := call.This
|
||||||
@@ -429,9 +422,8 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerJSONGlobal ensures JSON global is properly set up
|
// JSON is already built-in to Goja; this ensures a fallback exists.
|
||||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
// JSON is already built-in to Goja, but we can enhance it
|
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,8 +16,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Storage API ====================
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultStorageFlushDelay = 400 * time.Millisecond
|
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||||
storageFlushRetryDelay = 2 * time.Second
|
storageFlushRetryDelay = 2 * time.Second
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Utility functions for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,8 +16,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Utility Functions ====================
|
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension settings storage
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides timeout execution for extension JS code
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -489,7 +489,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check error message patterns for common ISP blocking indicators
|
|
||||||
blockingPatterns := []struct {
|
blockingPatterns := []struct {
|
||||||
pattern string
|
pattern string
|
||||||
reason string
|
reason string
|
||||||
@@ -532,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDomain extracts the domain from a URL string
|
|
||||||
func extractDomain(rawURL string) string {
|
func extractDomain(rawURL string) string {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ func (t *utlsTransport) getPort(u *url.URL) string {
|
|||||||
return "80"
|
return "80"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloudflare bypass client using uTLS Chrome fingerprint
|
|
||||||
var cloudflareBypassTransport = newUTLSTransport()
|
var cloudflareBypassTransport = newUTLSTransport()
|
||||||
|
|
||||||
var cloudflareBypassClient = &http.Client{
|
var cloudflareBypassClient = &http.Client{
|
||||||
@@ -111,7 +110,6 @@ func GetCloudflareBypassClient() *http.Client {
|
|||||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Try with standard client first
|
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check for Cloudflare challenge page (403 with specific markers)
|
// Check for Cloudflare challenge page (403 with specific markers)
|
||||||
@@ -138,11 +136,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
if isCloudflare {
|
if isCloudflare {
|
||||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||||
|
|
||||||
// Clone request for retry
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Retry with uTLS Chrome fingerprint
|
|
||||||
return cloudflareBypassClient.Do(reqCopy)
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,11 +164,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
if tlsRelated {
|
if tlsRelated {
|
||||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||||
|
|
||||||
// Clone request for retry
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Retry with uTLS Chrome fingerprint
|
|
||||||
return cloudflareBypassClient.Do(reqCopy)
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,11 @@ var (
|
|||||||
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
// IDHSSearchRequest represents the request body for IDHS API
|
|
||||||
type IDHSSearchRequest struct {
|
type IDHSSearchRequest struct {
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Adapters []string `json:"adapters,omitempty"`
|
Adapters []string `json:"adapters,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDHSSearchResponse represents the response from IDHS API
|
|
||||||
type IDHSSearchResponse struct {
|
type IDHSSearchResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"` // song, album, artist, podcast, show
|
Type string `json:"type"` // song, album, artist, podcast, show
|
||||||
@@ -41,7 +39,6 @@ type IDHSSearchResponse struct {
|
|||||||
Links []IDHSLink `json:"links"`
|
Links []IDHSLink `json:"links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDHSLink represents a link to a streaming platform
|
|
||||||
type IDHSLink struct {
|
type IDHSLink struct {
|
||||||
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -49,7 +46,6 @@ type IDHSLink struct {
|
|||||||
NotAvailable bool `json:"notAvailable,omitempty"`
|
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIDHSClient creates a new IDHS client
|
|
||||||
func NewIDHSClient() *IDHSClient {
|
func NewIDHSClient() *IDHSClient {
|
||||||
idhsClientOnce.Do(func() {
|
idhsClientOnce.Do(func() {
|
||||||
globalIDHSClient = &IDHSClient{
|
globalIDHSClient = &IDHSClient{
|
||||||
@@ -117,7 +113,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
|
|||||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
|
|
||||||
// Request only the platforms we need
|
|
||||||
adapters := []string{"tidal", "deezer"}
|
adapters := []string{"tidal", "deezer"}
|
||||||
|
|
||||||
result, err := c.Search(spotifyURL, adapters)
|
result, err := c.Search(spotifyURL, adapters)
|
||||||
@@ -151,11 +146,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailabilityFromDeezer checks track availability using IDHS
|
|
||||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
// Request only the platforms we need
|
|
||||||
adapters := []string{"spotify", "tidal"}
|
adapters := []string{"spotify", "tidal"}
|
||||||
|
|
||||||
result, err := c.Search(deezerURL, adapters)
|
result, err := c.Search(deezerURL, adapters)
|
||||||
|
|||||||
+119
-13
@@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LibraryScanResult represents metadata from a scanned audio file
|
|
||||||
type LibraryScanResult struct {
|
type LibraryScanResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TrackName string `json:"trackName"`
|
TrackName string `json:"trackName"`
|
||||||
@@ -42,7 +41,6 @@ type LibraryScanProgress struct {
|
|||||||
IsComplete bool `json:"is_complete"`
|
IsComplete bool `json:"is_complete"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementalScanResult contains results of an incremental library scan
|
|
||||||
type IncrementalScanResult struct {
|
type IncrementalScanResult struct {
|
||||||
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||||
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||||
@@ -65,6 +63,7 @@ var supportedAudioFormats = map[string]bool{
|
|||||||
".mp3": true,
|
".mp3": true,
|
||||||
".opus": true,
|
".opus": true,
|
||||||
".ogg": true,
|
".ogg": true,
|
||||||
|
".cue": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
type libraryAudioFileInfo struct {
|
type libraryAudioFileInfo struct {
|
||||||
@@ -168,6 +167,23 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
|
// Track audio files referenced by .cue sheets to avoid duplicates
|
||||||
|
cueReferencedAudioFiles := make(map[string]bool)
|
||||||
|
|
||||||
|
// First pass: scan .cue files to collect referenced audio paths
|
||||||
|
for _, filePath := range audioFiles {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == ".cue" {
|
||||||
|
sheet, err := ParseCueFile(filePath)
|
||||||
|
if err == nil && sheet.FileName != "" {
|
||||||
|
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
||||||
|
if audioPath != "" {
|
||||||
|
cueReferencedAudioFiles[audioPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, filePath := range audioFiles {
|
for i, filePath := range audioFiles {
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
@@ -181,6 +197,28 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
// Handle .cue files: produce multiple track results
|
||||||
|
if ext == ".cue" {
|
||||||
|
cueResults, err := ScanCueFileForLibrary(filePath, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, cueResults...)
|
||||||
|
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip audio files that are referenced by a .cue sheet
|
||||||
|
// (they will be represented by the cue sheet's track entries instead)
|
||||||
|
if cueReferencedAudioFiles[filePath] {
|
||||||
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
result, err := scanAudioFile(filePath, scanTime)
|
result, err := scanAudioFile(filePath, scanTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
@@ -216,7 +254,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
Format: strings.TrimPrefix(ext, "."),
|
Format: strings.TrimPrefix(ext, "."),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file modification time
|
|
||||||
if info, err := os.Stat(filePath); err == nil {
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
result.FileModTime = info.ModTime().UnixMilli()
|
result.FileModTime = info.ModTime().UnixMilli()
|
||||||
}
|
}
|
||||||
@@ -466,7 +503,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse existing files map
|
|
||||||
existingFiles := make(map[string]int64)
|
existingFiles := make(map[string]int64)
|
||||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||||
@@ -476,12 +512,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||||
|
|
||||||
// Reset progress
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress = LibraryScanProgress{}
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Setup cancellation
|
|
||||||
libraryScanCancelMu.Lock()
|
libraryScanCancelMu.Lock()
|
||||||
if libraryScanCancel != nil {
|
if libraryScanCancel != nil {
|
||||||
close(libraryScanCancel)
|
close(libraryScanCancel)
|
||||||
@@ -490,7 +524,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
cancelCh := libraryScanCancel
|
cancelCh := libraryScanCancel
|
||||||
libraryScanCancelMu.Unlock()
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
// Collect all audio files with their mod times
|
|
||||||
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "{}", err
|
return "{}", err
|
||||||
@@ -509,24 +542,64 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
var filesToScan []libraryAudioFileInfo
|
var filesToScan []libraryAudioFileInfo
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
|
|
||||||
|
// Build a set of existing CUE virtual path base files for incremental matching.
|
||||||
|
// CUE tracks are stored with virtual paths like "/path/album.cue#track01".
|
||||||
|
// We need to match these against the actual .cue file's modTime.
|
||||||
|
cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk
|
||||||
|
for _, f := range currentFiles {
|
||||||
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
|
cueBaseModTimes[f.path] = f.modTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
// New file
|
// For .cue files, also check if any virtual path entries exist
|
||||||
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
|
hasCueTracks := false
|
||||||
|
for existingPath := range existingFiles {
|
||||||
|
if strings.HasPrefix(existingPath, f.path+"#track") {
|
||||||
|
hasCueTracks = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasCueTracks {
|
||||||
|
// CUE file exists in DB via virtual paths; check if modTime changed
|
||||||
|
// Use modTime from any virtual path (they all share the same .cue modTime)
|
||||||
|
for existingPath, modTime := range existingFiles {
|
||||||
|
if strings.HasPrefix(existingPath, f.path+"#track") {
|
||||||
|
if f.modTime == modTime {
|
||||||
|
skippedCount++
|
||||||
|
} else {
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
filesToScan = append(filesToScan, f)
|
filesToScan = append(filesToScan, f)
|
||||||
} else if f.modTime != existingModTime {
|
} else if f.modTime != existingModTime {
|
||||||
// Modified file
|
|
||||||
filesToScan = append(filesToScan, f)
|
filesToScan = append(filesToScan, f)
|
||||||
} else {
|
} else {
|
||||||
// Unchanged file - skip
|
|
||||||
skippedCount++
|
skippedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find deleted files
|
|
||||||
var deletedPaths []string
|
var deletedPaths []string
|
||||||
for existingPath := range existingFiles {
|
for existingPath := range existingFiles {
|
||||||
if !currentPathSet[existingPath] {
|
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
|
||||||
|
// check if the base .cue file still exists on disk
|
||||||
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
|
baseCuePath := existingPath[:idx]
|
||||||
|
if currentPathSet[baseCuePath] {
|
||||||
|
continue // Base .cue file still exists, not deleted
|
||||||
|
}
|
||||||
|
// Base CUE file is gone, mark virtual path as deleted
|
||||||
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
|
} else if !currentPathSet[existingPath] {
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,11 +624,25 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan the files that need scanning
|
|
||||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
|
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
||||||
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||||
|
for _, f := range filesToScan {
|
||||||
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
if ext == ".cue" {
|
||||||
|
sheet, err := ParseCueFile(f.path)
|
||||||
|
if err == nil && sheet.FileName != "" {
|
||||||
|
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||||
|
if audioPath != "" {
|
||||||
|
cueReferencedAudioFilesInc[audioPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, f := range filesToScan {
|
for i, f := range filesToScan {
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
@@ -569,6 +656,25 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
|
// Handle .cue files: produce multiple track results
|
||||||
|
if ext == ".cue" {
|
||||||
|
cueResults, err := ScanCueFileForLibrary(f.path, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, cueResults...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip audio files referenced by .cue sheets
|
||||||
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
result, err := scanAudioFile(f.path, scanTime)
|
result, err := scanAudioFile(f.path, scanTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ var DefaultLyricsProviders = []string{
|
|||||||
LyricsProviderQQMusic,
|
LyricsProviderQQMusic,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global lyrics provider configuration
|
|
||||||
var (
|
var (
|
||||||
lyricsProvidersMu sync.RWMutex
|
lyricsProvidersMu sync.RWMutex
|
||||||
lyricsProviders []string // ordered list of enabled providers
|
lyricsProviders []string // ordered list of enabled providers
|
||||||
@@ -598,7 +597,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return lyricsHasUsableText(l)
|
return lyricsHasUsableText(l)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try extension lyrics providers first
|
|
||||||
if len(extensionProviders) > 0 {
|
if len(extensionProviders) > 0 {
|
||||||
for _, provider := range extensionProviders {
|
for _, provider := range extensionProviders {
|
||||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||||
@@ -621,7 +619,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return &cachedCopy, nil
|
return &cachedCopy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get configured provider order
|
|
||||||
providerOrder := GetLyricsProviderOrder()
|
providerOrder := GetLyricsProviderOrder()
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ func (m *appleTokenManager) clearToken() {
|
|||||||
m.token = ""
|
m.token = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Music API response models
|
|
||||||
type appleMusicSearchResponse struct {
|
type appleMusicSearchResponse struct {
|
||||||
Results struct {
|
Results struct {
|
||||||
Songs *struct {
|
Songs *struct {
|
||||||
@@ -239,15 +238,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
|||||||
return bodyStr, nil
|
return bodyStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
|
||||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||||
// Try to parse as PaxResponse first
|
|
||||||
var paxResp paxResponse
|
var paxResp paxResponse
|
||||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as a direct list of PaxLyrics
|
|
||||||
var directLyrics []paxLyrics
|
var directLyrics []paxLyrics
|
||||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type MusixmatchClient struct {
|
|||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Musixmatch proxy response models
|
|
||||||
type musixmatchSearchResponse struct {
|
type musixmatchSearchResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SongName string `json:"songName"`
|
SongName string `json:"songName"`
|
||||||
@@ -116,7 +115,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
|||||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer synced lyrics for selected language
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
@@ -129,7 +127,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to unsynced lyrics for selected language
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||||
|
|
||||||
@@ -162,7 +159,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
|
|||||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer synced lyrics
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
@@ -175,7 +171,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to unsynced lyrics
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type NeteaseClient struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Netease API response models
|
|
||||||
type neteaseSearchResponse struct {
|
type neteaseSearchResponse struct {
|
||||||
Result struct {
|
Result struct {
|
||||||
Songs []struct {
|
Songs []struct {
|
||||||
@@ -172,7 +171,6 @@ func (c *NeteaseClient) FetchLyrics(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the LRC text into LyricsResponse
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
// May be plain text lyrics without timestamps
|
// May be plain text lyrics without timestamps
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ type QQMusicClient struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ Music search response models
|
|
||||||
type qqMusicSearchResponse struct {
|
type qqMusicSearchResponse struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Song struct {
|
Song struct {
|
||||||
@@ -184,7 +183,6 @@ func (c *QQMusicClient) FetchLyrics(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to plain text
|
|
||||||
resultLines := plainTextLyricsLines(lrcText)
|
resultLines := plainTextLyricsLines(lrcText)
|
||||||
|
|
||||||
if len(resultLines) > 0 {
|
if len(resultLines) > 0 {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// mobile_deps.go
|
|
||||||
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
||||||
// These packages are required by gomobile bind but not directly imported in code.
|
// These packages are required by gomobile bind but not directly imported in code.
|
||||||
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// Required for gomobile bind to work
|
|
||||||
_ "golang.org/x/mobile/bind"
|
_ "golang.org/x/mobile/bind"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
type TrackIDCacheEntry struct {
|
type TrackIDCacheEntry struct {
|
||||||
TidalTrackID int64
|
TidalTrackID int64
|
||||||
QobuzTrackID int64
|
QobuzTrackID int64
|
||||||
AmazonURL string
|
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,25 +106,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
entry, exists := c.cache[isrc]
|
|
||||||
if !exists {
|
|
||||||
entry = &TrackIDCacheEntry{}
|
|
||||||
c.cache[isrc] = entry
|
|
||||||
}
|
|
||||||
entry.AmazonURL = amazonURL
|
|
||||||
now := time.Now()
|
|
||||||
entry.ExpiresAt = now.Add(c.ttl)
|
|
||||||
|
|
||||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
|
||||||
c.pruneExpiredLocked(now)
|
|
||||||
c.lastCleanup = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TrackIDCache) Clear() {
|
func (c *TrackIDCache) Clear() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||||
case "amazon":
|
|
||||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
|
||||||
}
|
}
|
||||||
}(req)
|
}(req)
|
||||||
}
|
}
|
||||||
@@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) {
|
|||||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||||
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
|
||||||
if spotifyID != "" {
|
if spotifyID != "" {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||||
// Parse QobuzID to int64
|
|
||||||
var trackID int64
|
var trackID int64
|
||||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||||
@@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Direct ISRC search on Qobuz API
|
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
@@ -280,14 +255,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
|
||||||
if err == nil && availability != nil && availability.AmazonURL != "" {
|
|
||||||
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
|
|||||||
+5
-12
@@ -923,18 +923,14 @@ type qobuzAPIResult struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Qobuz API timeout configuration
|
|
||||||
// Mobile networks are more unstable, so we use longer timeouts
|
// Mobile networks are more unstable, so we use longer timeouts
|
||||||
const (
|
const (
|
||||||
qobuzAPITimeoutMobile = 25 * time.Second
|
qobuzAPITimeoutMobile = 25 * time.Second
|
||||||
qobuzMaxRetries = 2 // Number of retries per API
|
qobuzMaxRetries = 2
|
||||||
qobuzRetryDelay = 500 * time.Millisecond
|
qobuzRetryDelay = 500 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// getQobuzAPITimeout returns appropriate timeout based on platform
|
|
||||||
// For mobile (gomobile builds), we use longer timeouts
|
|
||||||
func getQobuzAPITimeout() time.Duration {
|
func getQobuzAPITimeout() time.Duration {
|
||||||
// Since this runs in gomobile context, we always use mobile timeout
|
|
||||||
// The Go backend is only used on mobile (Android/iOS)
|
// The Go backend is only used on mobile (Android/iOS)
|
||||||
return qobuzAPITimeoutMobile
|
return qobuzAPITimeoutMobile
|
||||||
}
|
}
|
||||||
@@ -944,7 +940,6 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
|
|||||||
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
|
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
|
|
||||||
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
|
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
retryDelay := qobuzRetryDelay
|
retryDelay := qobuzRetryDelay
|
||||||
@@ -967,7 +962,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
|
|||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay)
|
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay)
|
||||||
time.Sleep(retryDelay)
|
time.Sleep(retryDelay)
|
||||||
retryDelay *= 2 // Exponential backoff
|
retryDelay *= 2
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(timeout)
|
client := NewHTTPClientWithTimeout(timeout)
|
||||||
@@ -1014,11 +1009,10 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
|
|||||||
strings.Contains(errStr, "reset") ||
|
strings.Contains(errStr, "reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
strings.Contains(errStr, "eof") {
|
strings.Contains(errStr, "eof") {
|
||||||
continue // Retry
|
continue
|
||||||
}
|
}
|
||||||
break // Non-retryable error
|
break
|
||||||
}
|
}
|
||||||
// Server errors are retryable
|
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -1031,7 +1025,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
|
|||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("rate limited")
|
lastErr = fmt.Errorf("rate limited")
|
||||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
retryDelay = 2 * time.Second
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1308,7 +1302,6 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
track = nil
|
track = nil
|
||||||
} else if track != nil {
|
} else if track != nil {
|
||||||
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
||||||
// Cache for future use
|
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() {
|
|||||||
r.timestamps = append(r.timestamps, time.Now())
|
r.timestamps = append(r.timestamps, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanOldTimestamps removes timestamps that are outside the current window
|
|
||||||
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||||
cutoff := now.Add(-r.window)
|
cutoff := now.Add(-r.window)
|
||||||
validStart := 0
|
validStart := 0
|
||||||
|
|||||||
@@ -170,11 +170,9 @@ func JapaneseToRomaji(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
// Convert Japanese to romaji
|
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
artistRomaji := JapaneseToRomaji(artistName)
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean up the query - remove special characters that might interfere with search
|
|
||||||
trackClean := cleanSearchQuery(trackRomaji)
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
artistClean := cleanSearchQuery(artistRomaji)
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
@@ -196,16 +194,13 @@ func cleanSearchQuery(s string) string {
|
|||||||
func CleanToASCII(s string) string {
|
func CleanToASCII(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
} else if r == ',' || r == '.' {
|
} else if r == ',' || r == '.' {
|
||||||
// Convert punctuation to space
|
|
||||||
result.WriteRune(' ')
|
result.WriteRune(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean up multiple spaces
|
|
||||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-12
@@ -291,7 +291,7 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
// extractQobuzIDFromURL extracts Qobuz track ID from URL.
|
||||||
// URL formats:
|
// URL formats:
|
||||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||||
// - https://open.qobuz.com/track/12345678
|
// - https://open.qobuz.com/track/12345678
|
||||||
@@ -302,29 +302,24 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find /track/ID pattern first
|
|
||||||
if strings.Contains(qobuzURL, "/track/") {
|
if strings.Contains(qobuzURL, "/track/") {
|
||||||
parts := strings.Split(qobuzURL, "/track/")
|
parts := strings.Split(qobuzURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
idPart := parts[1]
|
idPart := parts[1]
|
||||||
// Remove query parameters
|
|
||||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
}
|
}
|
||||||
// Remove trailing slash or path
|
|
||||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
}
|
}
|
||||||
idPart = strings.TrimSpace(idPart)
|
idPart = strings.TrimSpace(idPart)
|
||||||
// Validate it's a number
|
|
||||||
if idPart != "" && isNumeric(idPart) {
|
if idPart != "" && isNumeric(idPart) {
|
||||||
return idPart
|
return idPart
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract from album URL with track highlight
|
// Try to extract from album URL with track highlight (e.g. ?trackId=12345678)
|
||||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
|
||||||
if strings.Contains(qobuzURL, "trackId=") {
|
if strings.Contains(qobuzURL, "trackId=") {
|
||||||
parts := strings.Split(qobuzURL, "trackId=")
|
parts := strings.Split(qobuzURL, "trackId=")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
@@ -343,7 +338,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
|||||||
parts := strings.Split(qobuzURL, "/")
|
parts := strings.Split(qobuzURL, "/")
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
part := parts[i]
|
part := parts[i]
|
||||||
// Remove query parameters
|
|
||||||
if idx := strings.Index(part, "?"); idx > 0 {
|
if idx := strings.Index(part, "?"); idx > 0 {
|
||||||
part = part[:idx]
|
part = part[:idx]
|
||||||
}
|
}
|
||||||
@@ -386,7 +380,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle youtu.be short URLs
|
|
||||||
if strings.Contains(youtubeURL, "youtu.be/") {
|
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||||
parts := strings.Split(youtubeURL, "youtu.be/")
|
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -401,7 +394,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle youtube.com URLs with ?v= parameter
|
|
||||||
parsed, err := url.Parse(youtubeURL)
|
parsed, err := url.Parse(youtubeURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -411,7 +403,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle /embed/ format
|
|
||||||
if strings.Contains(parsed.Path, "/embed/") {
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
parts := strings.Split(parsed.Path, "/embed/")
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -540,7 +531,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
|
||||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
|||||||
+3
-29
@@ -9,7 +9,6 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -64,45 +63,20 @@ var (
|
|||||||
credentialsMu sync.RWMutex
|
credentialsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
|
||||||
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
customClientID = clientID
|
customClientID = ""
|
||||||
customClientSecret = clientSecret
|
customClientSecret = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func HasSpotifyCredentials() bool {
|
func HasSpotifyCredentials() bool {
|
||||||
credentialsMu.RLock()
|
|
||||||
defer credentialsMu.RUnlock()
|
|
||||||
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCredentials() (string, string, error) {
|
func getCredentials() (string, string, error) {
|
||||||
credentialsMu.RLock()
|
|
||||||
defer credentialsMu.RUnlock()
|
|
||||||
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
|
||||||
return customClientID, customClientSecret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
|
||||||
|
|
||||||
if clientID != "" && clientSecret != "" {
|
|
||||||
return clientID, clientSecret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", ErrNoSpotifyCredentials
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-13
@@ -103,7 +103,7 @@ type MPD struct {
|
|||||||
func NewTidalDownloader() *TidalDownloader {
|
func NewTidalDownloader() *TidalDownloader {
|
||||||
tidalDownloaderOnce.Do(func() {
|
tidalDownloaderOnce.Do(func() {
|
||||||
globalTidalDownloader = &TidalDownloader{
|
globalTidalDownloader = &TidalDownloader{
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
client: NewHTTPClientWithTimeout(DefaultTimeout),
|
||||||
}
|
}
|
||||||
|
|
||||||
apis := globalTidalDownloader.GetAvailableAPIs()
|
apis := globalTidalDownloader.GetAvailableAPIs()
|
||||||
@@ -116,7 +116,7 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"https://tidal-api.binimum.org", // priority
|
"https://tidal-api.binimum.org",
|
||||||
"https://tidal.kinoplus.online",
|
"https://tidal.kinoplus.online",
|
||||||
"https://triton.squid.wtf",
|
"https://triton.squid.wtf",
|
||||||
"https://vogel.qqdl.site",
|
"https://vogel.qqdl.site",
|
||||||
@@ -195,7 +195,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
|
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||||
}
|
}
|
||||||
@@ -204,7 +203,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TidalDownloadInfo contains download URL and quality info
|
|
||||||
type TidalDownloadInfo struct {
|
type TidalDownloadInfo struct {
|
||||||
URL string
|
URL string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -218,15 +216,13 @@ type tidalAPIResult struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tidal API timeout configuration
|
|
||||||
// Mobile networks are more unstable, so we use longer timeouts
|
// Mobile networks are more unstable, so we use longer timeouts
|
||||||
const (
|
const (
|
||||||
tidalAPITimeoutMobile = 25 * time.Second
|
tidalAPITimeoutMobile = 25 * time.Second
|
||||||
tidalMaxRetries = 2 // Number of retries per API
|
tidalMaxRetries = 2
|
||||||
tidalRetryDelay = 500 * time.Millisecond
|
tidalRetryDelay = 500 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
|
|
||||||
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
|
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
retryDelay := tidalRetryDelay
|
retryDelay := tidalRetryDelay
|
||||||
@@ -235,7 +231,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
|||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
|
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
|
||||||
time.Sleep(retryDelay)
|
time.Sleep(retryDelay)
|
||||||
retryDelay *= 2 // Exponential backoff
|
retryDelay *= 2
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(timeout)
|
client := NewHTTPClientWithTimeout(timeout)
|
||||||
@@ -250,17 +246,15 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
|||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
// Check for retryable errors (timeout, connection reset)
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
if strings.Contains(errStr, "timeout") ||
|
if strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "reset") ||
|
strings.Contains(errStr, "reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
strings.Contains(errStr, "eof") {
|
strings.Contains(errStr, "eof") {
|
||||||
continue // Retry
|
continue
|
||||||
}
|
}
|
||||||
break // Non-retryable error
|
break
|
||||||
}
|
}
|
||||||
// Server errors are retryable
|
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -273,7 +267,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
|||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("rate limited")
|
lastErr = fmt.Errorf("rate limited")
|
||||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
retryDelay = 2 * time.Second
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -161,7 +160,6 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchYouTube returns a YouTube Music search URL for the given track
|
|
||||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||||
searchQuery := url.QueryEscape(query)
|
searchQuery := url.QueryEscape(query)
|
||||||
@@ -213,7 +211,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestCobaltDirect sends a download request to the primary Cobalt API.
|
|
||||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
reqBody := CobaltRequest{
|
reqBody := CobaltRequest{
|
||||||
URL: videoURL,
|
URL: videoURL,
|
||||||
@@ -470,7 +467,6 @@ func BuildYouTubeWatchURL(videoID string) string {
|
|||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
|
|
||||||
func isYouTubeVideoID(s string) bool {
|
func isYouTubeVideoID(s string) bool {
|
||||||
if len(s) != 11 {
|
if len(s) != 11 {
|
||||||
return false
|
return false
|
||||||
@@ -707,7 +703,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|||||||
|
|
||||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||||
|
|
||||||
// Parallel fetch cover art + lyrics
|
|
||||||
var parallelResult *ParallelDownloadResult
|
var parallelResult *ParallelDownloadResult
|
||||||
if req.EmbedLyrics || req.CoverURL != "" {
|
if req.EmbedLyrics || req.CoverURL != "" {
|
||||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||||
|
|||||||
+138
-50
@@ -15,6 +15,9 @@ import Gobackend // Import Go framework
|
|||||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||||
private var lastLibraryScanProgressPayload: String?
|
private var lastLibraryScanProgressPayload: String?
|
||||||
|
|
||||||
|
/// Currently accessed security-scoped URL for library folder
|
||||||
|
private var activeSecurityScopedURL: URL?
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
@@ -157,38 +160,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getSpotifyMetadata":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendGetSpotifyMetadata(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "searchSpotify":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let query = args["query"] as! String
|
|
||||||
let limit = args["limit"] as? Int ?? 10
|
|
||||||
let response = GobackendSearchSpotify(query, Int(limit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "searchSpotifyAll":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let query = args["query"] as! String
|
|
||||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
|
||||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
|
||||||
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "getSpotifyRelatedArtists":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let artistId = args["artist_id"] as! String
|
|
||||||
let limit = args["limit"] as? Int ?? 12
|
|
||||||
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -492,13 +463,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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
|
||||||
@@ -514,17 +478,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearTrackCache()
|
GobackendClearTrackCache()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "setSpotifyCredentials":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let clientId = args["client_id"] as! String
|
|
||||||
let clientSecret = args["client_secret"] as! String
|
|
||||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "hasSpotifyCredentials":
|
|
||||||
let hasCredentials = GobackendCheckSpotifyCredentials()
|
|
||||||
return hasCredentials
|
|
||||||
|
|
||||||
// Log methods
|
// Log methods
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
@@ -922,6 +875,26 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
// iOS Security-Scoped Bookmark for Local Library
|
||||||
|
case "resolveIosBookmark":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let bookmarkBase64 = args["bookmark"] as! String
|
||||||
|
return try resolveIosBookmark(bookmarkBase64)
|
||||||
|
|
||||||
|
case "startAccessingIosBookmark":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let bookmarkBase64 = args["bookmark"] as! String
|
||||||
|
return try startAccessingIosBookmark(bookmarkBase64)
|
||||||
|
|
||||||
|
case "stopAccessingIosBookmark":
|
||||||
|
stopAccessingIosBookmark()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "createIosBookmarkFromPath":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let path = args["path"] as! String
|
||||||
|
return try createIosBookmarkFromPath(path)
|
||||||
|
|
||||||
// Lyrics Provider Settings
|
// Lyrics Provider Settings
|
||||||
case "setLyricsProviders":
|
case "setLyricsProviders":
|
||||||
@@ -953,6 +926,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
// CUE Sheet Parsing
|
||||||
|
case "parseCueSheet":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let cuePath = args["cue_path"] as! String
|
||||||
|
let audioDir = args["audio_dir"] as? String ?? ""
|
||||||
|
let response = GobackendParseCueSheet(cuePath, audioDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
@@ -961,6 +943,112 @@ import Gobackend // Import Go framework
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - iOS Security-Scoped Bookmark Helpers
|
||||||
|
|
||||||
|
/// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker).
|
||||||
|
/// The path must currently be accessible (within the same picker session).
|
||||||
|
/// Returns base64-encoded bookmark data.
|
||||||
|
private func createIosBookmarkFromPath(_ path: String) throws -> String {
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
do {
|
||||||
|
let bookmarkData = try url.bookmarkData(
|
||||||
|
options: .minimalBookmark,
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil
|
||||||
|
)
|
||||||
|
return bookmarkData.base64EncodedString()
|
||||||
|
} catch {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a base64-encoded security-scoped bookmark and return the resolved path.
|
||||||
|
/// Does NOT start accessing the resource.
|
||||||
|
private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String {
|
||||||
|
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isStale = false
|
||||||
|
let url: URL
|
||||||
|
do {
|
||||||
|
url = try URL(
|
||||||
|
resolvingBookmarkData: bookmarkData,
|
||||||
|
options: [],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a base64-encoded bookmark, start accessing the security-scoped resource,
|
||||||
|
/// and return the resolved filesystem path. The resource stays accessed until
|
||||||
|
/// `stopAccessingIosBookmark()` is called.
|
||||||
|
private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> String {
|
||||||
|
// Stop any previously accessed resource first
|
||||||
|
stopAccessingIosBookmark()
|
||||||
|
|
||||||
|
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isStale = false
|
||||||
|
let url: URL
|
||||||
|
do {
|
||||||
|
url = try URL(
|
||||||
|
resolvingBookmarkData: bookmarkData,
|
||||||
|
options: [],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard url.startAccessingSecurityScopedResource() else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSecurityScopedURL = url
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accessing the currently active security-scoped resource, if any.
|
||||||
|
private func stopAccessingIosBookmark() {
|
||||||
|
if let url = activeSecurityScopedURL {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
activeSecurityScopedURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
|
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
settingsProvider.select((s) => s.hasCompletedTutorial),
|
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine initial location based on app state
|
|
||||||
String initialLocation;
|
String initialLocation;
|
||||||
if (isFirstLaunch) {
|
if (isFirstLaunch) {
|
||||||
initialLocation = '/setup';
|
initialLocation = '/setup';
|
||||||
|
|||||||
@@ -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.7.1';
|
static const String version = '3.7.2';
|
||||||
static const String buildNumber = '104';
|
static const String buildNumber = '105';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ abstract class AppLocalizations {
|
|||||||
/// App description in header card
|
/// App description in header card
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'**
|
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
|
||||||
String get aboutAppDescription;
|
String get aboutAppDescription;
|
||||||
|
|
||||||
/// Section header for artist albums
|
/// Section header for artist albums
|
||||||
@@ -1306,6 +1306,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'No tracks found'**
|
/// **'No tracks found'**
|
||||||
String get errorNoTracksFound;
|
String get errorNoTracksFound;
|
||||||
|
|
||||||
|
/// Error title - URL not handled by any extension or service
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Link not recognized'**
|
||||||
|
String get errorUrlNotRecognized;
|
||||||
|
|
||||||
|
/// Error message - URL not recognized explanation
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'**
|
||||||
|
String get errorUrlNotRecognizedMessage;
|
||||||
|
|
||||||
|
/// Error message - generic URL fetch failure
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load content from this link. Please try again.'**
|
||||||
|
String get errorUrlFetchFailed;
|
||||||
|
|
||||||
/// Error - extension source not available
|
/// Error - extension source not available
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1438,6 +1456,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'No organization'**
|
/// **'No organization'**
|
||||||
String get folderOrganizationNone;
|
String get folderOrganizationNone;
|
||||||
|
|
||||||
|
/// Folder option - playlist folders
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'By Playlist'**
|
||||||
|
String get folderOrganizationByPlaylist;
|
||||||
|
|
||||||
|
/// Subtitle for playlist folder option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Separate folder for each playlist'**
|
||||||
|
String get folderOrganizationByPlaylistSubtitle;
|
||||||
|
|
||||||
/// Folder option - artist folders
|
/// Folder option - artist folders
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1576,7 +1606,7 @@ abstract class AppLocalizations {
|
|||||||
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
|
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
|
||||||
String get providerPriorityInfo;
|
String get providerPriorityInfo;
|
||||||
|
|
||||||
/// Label for built-in providers (Tidal/Qobuz/Amazon)
|
/// Label for built-in providers (Tidal/Qobuz)
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Built-in'**
|
/// **'Built-in'**
|
||||||
@@ -3271,7 +3301,7 @@ abstract class AppLocalizations {
|
|||||||
/// Tutorial welcome tip 2
|
/// Tutorial welcome tip 2
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'**
|
/// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'**
|
||||||
String get tutorialWelcomeTip2;
|
String get tutorialWelcomeTip2;
|
||||||
|
|
||||||
/// Tutorial welcome tip 3
|
/// Tutorial welcome tip 3
|
||||||
@@ -3794,6 +3824,78 @@ abstract class AppLocalizations {
|
|||||||
/// **'Conversion failed'**
|
/// **'Conversion failed'**
|
||||||
String get trackConvertFailed;
|
String get trackConvertFailed;
|
||||||
|
|
||||||
|
/// Title for CUE split bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Split CUE Sheet'**
|
||||||
|
String get cueSplitTitle;
|
||||||
|
|
||||||
|
/// Subtitle for CUE split menu item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Split CUE+FLAC into individual tracks'**
|
||||||
|
String get cueSplitSubtitle;
|
||||||
|
|
||||||
|
/// Album name in CUE split sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album: {album}'**
|
||||||
|
String cueSplitAlbum(String album);
|
||||||
|
|
||||||
|
/// Artist name in CUE split sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist: {artist}'**
|
||||||
|
String cueSplitArtist(String artist);
|
||||||
|
|
||||||
|
/// Number of tracks in CUE sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks'**
|
||||||
|
String cueSplitTrackCount(int count);
|
||||||
|
|
||||||
|
/// CUE split confirmation dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Split CUE Album'**
|
||||||
|
String get cueSplitConfirmTitle;
|
||||||
|
|
||||||
|
/// CUE split confirmation dialog message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.'**
|
||||||
|
String cueSplitConfirmMessage(String album, int count);
|
||||||
|
|
||||||
|
/// Snackbar while splitting CUE
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Splitting CUE sheet... ({current}/{total})'**
|
||||||
|
String cueSplitSplitting(int current, int total);
|
||||||
|
|
||||||
|
/// Snackbar after successful CUE split
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Split into {count} tracks successfully'**
|
||||||
|
String cueSplitSuccess(int count);
|
||||||
|
|
||||||
|
/// Snackbar when CUE split fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CUE split failed'**
|
||||||
|
String get cueSplitFailed;
|
||||||
|
|
||||||
|
/// Error when CUE audio file is missing
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Audio file not found for this CUE sheet'**
|
||||||
|
String get cueSplitNoAudioFile;
|
||||||
|
|
||||||
|
/// Button text to start CUE splitting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Split into Tracks'**
|
||||||
|
String get cueSplitButton;
|
||||||
|
|
||||||
/// Generic action button - create
|
/// Generic action button - create
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
+422
-345
File diff suppressed because it is too large
Load Diff
@@ -356,7 +356,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Albums';
|
String get artistAlbums => 'Albums';
|
||||||
@@ -688,6 +688,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No tracks found';
|
String get errorNoTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Cannot load $item: missing extension source';
|
return 'Cannot load $item: missing extension source';
|
||||||
@@ -761,6 +772,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'No organization';
|
String get folderOrganizationNone => 'No organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'By Artist';
|
String get folderOrganizationByArtist => 'By Artist';
|
||||||
|
|
||||||
@@ -1809,7 +1827,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2126,6 +2144,54 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Albums';
|
String get artistAlbums => 'Albums';
|
||||||
@@ -688,6 +688,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No tracks found';
|
String get errorNoTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Cannot load $item: missing extension source';
|
return 'Cannot load $item: missing extension source';
|
||||||
@@ -761,6 +772,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'No organization';
|
String get folderOrganizationNone => 'No organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'By Artist';
|
String get folderOrganizationByArtist => 'By Artist';
|
||||||
|
|
||||||
@@ -1809,7 +1827,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2126,6 +2144,54 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
@@ -2705,7 +2771,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.';
|
'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Álbumes';
|
String get artistAlbums => 'Álbumes';
|
||||||
@@ -4150,7 +4216,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
|
|||||||
@@ -690,6 +690,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No tracks found';
|
String get errorNoTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Cannot load $item: missing extension source';
|
return 'Cannot load $item: missing extension source';
|
||||||
@@ -763,6 +774,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'No organization';
|
String get folderOrganizationNone => 'No organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'By Artist';
|
String get folderOrganizationByArtist => 'By Artist';
|
||||||
|
|
||||||
@@ -2128,6 +2146,54 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
|||||||
@@ -688,6 +688,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No tracks found';
|
String get errorNoTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Cannot load $item: missing extension source';
|
return 'Cannot load $item: missing extension source';
|
||||||
@@ -761,6 +772,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'No organization';
|
String get folderOrganizationNone => 'No organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'By Artist';
|
String get folderOrganizationByArtist => 'By Artist';
|
||||||
|
|
||||||
@@ -2126,6 +2144,54 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get navHome => 'Beranda';
|
String get navHome => 'Beranda';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navLibrary => 'Library';
|
String get navLibrary => 'Pustaka';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navSettings => 'Pengaturan';
|
String get navSettings => 'Pengaturan';
|
||||||
@@ -45,7 +45,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get historyFilterSingles => 'Single';
|
String get historyFilterSingles => 'Single';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historySearchHint => 'Search history...';
|
String get historySearchHint => 'Cari riwayat...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Pengaturan';
|
String get settingsTitle => 'Pengaturan';
|
||||||
@@ -104,7 +104,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get appearanceHistoryViewList => 'Daftar';
|
String get appearanceHistoryViewList => 'Daftar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewGrid => 'Grid';
|
String get appearanceHistoryViewGrid => 'Kisi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsTitle => 'Opsi';
|
String get optionsTitle => 'Opsi';
|
||||||
@@ -126,7 +126,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
|
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallback => 'Auto Fallback';
|
String get optionsAutoFallback => 'Cadangan Otomatis';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallbackSubtitle =>
|
String get optionsAutoFallbackSubtitle =>
|
||||||
@@ -217,7 +217,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsSpotifyCredentialsConfigured(String clientId) {
|
String optionsSpotifyCredentialsConfigured(String clientId) {
|
||||||
return 'Client ID: $clientId...';
|
return 'ID Klien: $clientId...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -230,7 +230,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSpotifyDeprecationWarning =>
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Ekstensi';
|
String get extensionsTitle => 'Ekstensi';
|
||||||
@@ -283,7 +283,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
|
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutTranslators => 'Translators';
|
String get aboutTranslators => 'Penerjemah';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Terima Kasih Khusus';
|
String get aboutSpecialThanks => 'Terima Kasih Khusus';
|
||||||
@@ -311,19 +311,19 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Sarankan fitur baru untuk aplikasi';
|
'Sarankan fitur baru untuk aplikasi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutTelegramChannel => 'Telegram Channel';
|
String get aboutTelegramChannel => 'Saluran Telegram';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
String get aboutTelegramChannelSubtitle => 'Pengumuman dan pembaruan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutTelegramChat => 'Telegram Community';
|
String get aboutTelegramChat => 'Komunitas Telegram';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
String get aboutTelegramChatSubtitle => 'Berbincang dengan pengguna lain';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSocial => 'Social';
|
String get aboutSocial => 'Sosial';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Aplikasi';
|
String get aboutApp => 'Aplikasi';
|
||||||
@@ -341,7 +341,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSjdonadoDesc =>
|
String get aboutSjdonadoDesc =>
|
||||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
'Pencipta I Don\'t Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutDabMusic => 'DAB Music';
|
String get aboutDabMusic => 'DAB Music';
|
||||||
@@ -355,7 +355,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpotiSaverDesc =>
|
String get aboutSpotiSaverDesc =>
|
||||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
'Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
@@ -456,7 +456,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupIcloudNotSupported =>
|
String get setupIcloudNotSupported =>
|
||||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
'iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
|
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
|
||||||
@@ -593,7 +593,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String csvImportTracks(int count) {
|
String csvImportTracks(int count) {
|
||||||
return '$count tracks from CSV';
|
return '$count trek dari CSV';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -613,7 +613,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String snackbarAlreadyInLibrary(String trackName) {
|
String snackbarAlreadyInLibrary(String trackName) {
|
||||||
return '\"$trackName\" already exists in your library';
|
return '\"$trackName\" sudah ada di perpustakaan Anda';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -691,6 +691,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
|
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link tidak dikenali';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Gagal memuat konten dari link ini. Silakan coba lagi.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
|
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
|
||||||
@@ -755,15 +766,22 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get filenameFormat => 'Format Nama File';
|
String get filenameFormat => 'Format Nama File';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTagsDescription =>
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'Tidak ada';
|
String get folderOrganizationNone => 'Tidak ada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'Berdasarkan Artis';
|
String get folderOrganizationByArtist => 'Berdasarkan Artis';
|
||||||
|
|
||||||
@@ -1343,10 +1361,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube';
|
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube';
|
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||||
@@ -1684,8 +1702,8 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'trek',
|
other: 'tracks',
|
||||||
one: 'trek',
|
one: 'track',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -2134,10 +2152,58 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Buat';
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionFoldersTitle => 'Folder saya';
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionFoldersTitle => 'My folders';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionWishlist => 'Wishlist';
|
String get collectionWishlist => 'Wishlist';
|
||||||
@@ -2146,172 +2212,171 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get collectionLoved => 'Loved';
|
String get collectionLoved => 'Loved';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylists => 'Playlist';
|
String get collectionPlaylists => 'Playlists';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylist => 'Playlist';
|
String get collectionPlaylist => 'Playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionAddToPlaylist => 'Tambahkan ke playlist';
|
String get collectionAddToPlaylist => 'Add to playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionCreatePlaylist => 'Buat playlist';
|
String get collectionCreatePlaylist => 'Create playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionNoPlaylistsYet => 'Belum ada playlist';
|
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionNoPlaylistsSubtitle =>
|
String get collectionNoPlaylistsSubtitle =>
|
||||||
'Buat playlist untuk mulai mengategorikan lagu';
|
'Create a playlist to start categorizing tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionPlaylistTracks(int count) {
|
String collectionPlaylistTracks(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count lagu',
|
other: '$count tracks',
|
||||||
one: '1 lagu',
|
one: '1 track',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAddedToPlaylist(String playlistName) {
|
String collectionAddedToPlaylist(String playlistName) {
|
||||||
return 'Ditambahkan ke \"$playlistName\"';
|
return 'Added to \"$playlistName\"';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAlreadyInPlaylist(String playlistName) {
|
String collectionAlreadyInPlaylist(String playlistName) {
|
||||||
return 'Sudah ada di \"$playlistName\"';
|
return 'Already in \"$playlistName\"';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistCreated => 'Playlist berhasil dibuat';
|
String get collectionPlaylistCreated => 'Playlist created';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistNameHint => 'Nama playlist';
|
String get collectionPlaylistNameHint => 'Playlist name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi';
|
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionRenamePlaylist => 'Ubah nama playlist';
|
String get collectionRenamePlaylist => 'Rename playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionDeletePlaylist => 'Hapus playlist';
|
String get collectionDeletePlaylist => 'Delete playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionDeletePlaylistMessage(String playlistName) {
|
String collectionDeletePlaylistMessage(String playlistName) {
|
||||||
return 'Hapus \"$playlistName\" beserta semua lagunya?';
|
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistDeleted => 'Playlist dihapus';
|
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistRenamed => 'Nama playlist diperbarui';
|
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionWishlistEmptyTitle => 'Wishlist masih kosong';
|
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionWishlistEmptySubtitle =>
|
String get collectionWishlistEmptySubtitle =>
|
||||||
'Tap + di lagu untuk menyimpan yang ingin diunduh nanti';
|
'Tap + on tracks to save what you want to download later';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionLovedEmptyTitle => 'Folder Loved masih kosong';
|
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionLovedEmptySubtitle =>
|
String get collectionLovedEmptySubtitle =>
|
||||||
'Tap love di lagu untuk menyimpan favoritmu';
|
'Tap love on tracks to keep your favorites';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistEmptyTitle => 'Playlist masih kosong';
|
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistEmptySubtitle =>
|
String get collectionPlaylistEmptySubtitle =>
|
||||||
'Tekan lama tombol + pada lagu untuk menambahkannya ke sini';
|
'Long-press + on any track to add it here';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionRemoveFromPlaylist => 'Hapus dari playlist';
|
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionRemoveFromFolder => 'Hapus dari folder';
|
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionRemoved(String trackName) {
|
String collectionRemoved(String trackName) {
|
||||||
return '\"$trackName\" dihapus';
|
return '\"$trackName\" removed';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAddedToLoved(String trackName) {
|
String collectionAddedToLoved(String trackName) {
|
||||||
return '\"$trackName\" ditambahkan ke Loved';
|
return '\"$trackName\" added to Loved';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionRemovedFromLoved(String trackName) {
|
String collectionRemovedFromLoved(String trackName) {
|
||||||
return '\"$trackName\" dihapus dari Loved';
|
return '\"$trackName\" removed from Loved';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAddedToWishlist(String trackName) {
|
String collectionAddedToWishlist(String trackName) {
|
||||||
return '\"$trackName\" ditambahkan ke Wishlist';
|
return '\"$trackName\" added to Wishlist';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionRemovedFromWishlist(String trackName) {
|
String collectionRemovedFromWishlist(String trackName) {
|
||||||
return '\"$trackName\" dihapus dari Wishlist';
|
return '\"$trackName\" removed from Wishlist';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionAddToLoved => 'Tambahkan ke Loved';
|
String get trackOptionAddToLoved => 'Add to Loved';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionRemoveFromLoved => 'Hapus dari Loved';
|
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist';
|
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist';
|
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistChangeCover => 'Ubah gambar sampul';
|
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistRemoveCover => 'Hapus gambar sampul';
|
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionShareCount(int count) {
|
String selectionShareCount(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'trek',
|
other: 'tracks',
|
||||||
one: 'trek',
|
one: 'track',
|
||||||
);
|
);
|
||||||
return 'Bagikan $count $_temp0';
|
return 'Share $count $_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionConvertCount(int count) {
|
String selectionConvertCount(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'trek',
|
other: 'tracks',
|
||||||
one: 'trek',
|
one: 'track',
|
||||||
);
|
);
|
||||||
return 'Konversi $count $_temp0';
|
return 'Convert $count $_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionConvertNoConvertible =>
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
'Tidak ada trek yang dapat dikonversi dipilih';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertConfirmMessage(
|
String selectionBatchConvertConfirmMessage(
|
||||||
@@ -2322,20 +2387,20 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'trek',
|
other: 'tracks',
|
||||||
one: 'trek',
|
one: 'track',
|
||||||
);
|
);
|
||||||
return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Mengonversi $current dari $total...';
|
return 'Converting $current of $total...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
return 'Berhasil mengonversi $success dari $total trek ke $format';
|
return 'Converted $success of $total tracks to $format';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get navHome => 'ホーム';
|
String get navHome => 'ホーム';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navLibrary => 'Library';
|
String get navLibrary => 'ライブラリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navSettings => '設定';
|
String get navSettings => '設定';
|
||||||
@@ -160,7 +160,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsConcurrentParallel(int count) {
|
String optionsConcurrentParallel(int count) {
|
||||||
return '$count parallel downloads';
|
return '$count 件の分割ダウンロード';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -683,6 +683,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'トラックがありません';
|
String get errorNoTracksFound => 'トラックがありません';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return '$item を読み込めません: 拡張ソースがありません';
|
return '$item を読み込めません: 拡張ソースがありません';
|
||||||
@@ -756,6 +767,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => '構成がありません';
|
String get folderOrganizationNone => '構成がありません';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'アーティスト別';
|
String get folderOrganizationByArtist => 'アーティスト別';
|
||||||
|
|
||||||
@@ -1111,7 +1129,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get trackLyricsLoadFailed => '歌詞の読み込みに失敗しました';
|
String get trackLyricsLoadFailed => '歌詞の読み込みに失敗しました';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
String get trackEmbedLyrics => '歌詞を埋め込む';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
@@ -1325,10 +1343,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||||
@@ -1375,20 +1393,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
|
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
String get settingsAutoExportFailed => 'ダウンロードの自動エクスポートに失敗しました';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAutoExportFailedSubtitle =>
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
'Save failed downloads to TXT file automatically';
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetwork => 'Download Network';
|
String get settingsDownloadNetwork => 'ダウンロードネットワーク';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
String get settingsDownloadNetworkAny => 'Wi-Fi + モバイルデータ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
String get settingsDownloadNetworkWifiOnly => 'Wi-Fi のみ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkSubtitle =>
|
String get settingsDownloadNetworkSubtitle =>
|
||||||
@@ -1419,7 +1437,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get albumFolderYearAlbumSubtitle => 'アルバム/[2005] アルバム名/';
|
String get albumFolderYearAlbumSubtitle => 'アルバム/[2005] アルバム名/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
String get albumFolderArtistAlbumSingles => 'アーティスト / アルバム + シングル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
@@ -1485,7 +1503,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get recentEmpty => 'No recent items yet';
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recentShowAllDownloads => 'Show All Downloads';
|
String get recentShowAllDownloads => 'すべてのダウンロードを表示';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
@@ -1559,10 +1577,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました';
|
String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionStorageAccess => 'Storage Access';
|
String get sectionStorageAccess => 'ストレージアクセス';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get allFilesAccess => 'All Files Access';
|
String get allFilesAccess => 'すべてのファイルへのアクセス';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||||
@@ -1583,35 +1601,35 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'All Files Access disabled. The app will use limited storage access.';
|
'All Files Access disabled. The app will use limited storage access.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsLocalLibrary => 'Local Library';
|
String get settingsLocalLibrary => 'ローカルライブラリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsCache => 'Storage & Cache';
|
String get settingsCache => 'ストレージとキャッシュ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'ローカルライブラリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryScanSettings => 'Scan Settings';
|
String get libraryScanSettings => 'スキャン設定';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
String get libraryEnableLocalLibrary => 'ローカルライブラリを有効';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryEnableLocalLibrarySubtitle =>
|
String get libraryEnableLocalLibrarySubtitle =>
|
||||||
'Scan and track your existing music';
|
'Scan and track your existing music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFolder => 'Library Folder';
|
String get libraryFolder => 'ライブラリのフォルダ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFolderHint => 'Tap to select folder';
|
String get libraryFolderHint => 'タップでフォルダを選択';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||||
@@ -1621,13 +1639,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'アクション';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryScan => 'Scan Library';
|
String get libraryScan => 'ライブラリをスキャン';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryScanSubtitle => 'Scan for audio files';
|
String get libraryScanSubtitle => 'オーディオファイルをスキャン';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||||
@@ -1640,20 +1658,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Remove entries for files that no longer exist';
|
'Remove entries for files that no longer exist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryClear => 'Clear Library';
|
String get libraryClear => 'ライブラリを消去';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryClearConfirmTitle => 'Clear Library';
|
String get libraryClearConfirmTitle => 'ライブラリを消去';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryClearConfirmMessage =>
|
String get libraryClearConfirmMessage =>
|
||||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryAbout => 'About Local Library';
|
String get libraryAbout => 'ローカルライブラリについて';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryAboutDescription =>
|
String get libraryAboutDescription =>
|
||||||
@@ -1672,14 +1690,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return '最終スキャン: $time';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryLastScannedNever => 'Never';
|
String get libraryLastScannedNever => 'Never';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'スキャン中...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
@@ -1687,7 +1705,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryInLibrary => 'In Library';
|
String get libraryInLibrary => 'ライブラリ内';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryRemovedMissingFiles(int count) {
|
String libraryRemovedMissingFiles(int count) {
|
||||||
@@ -1698,7 +1716,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get libraryCleared => 'Library cleared';
|
String get libraryCleared => 'Library cleared';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
String get libraryStorageAccessRequired => 'ストレージアクセスが必要です';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryStorageAccessMessage =>
|
String get libraryStorageAccessMessage =>
|
||||||
@@ -1708,37 +1726,37 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get librarySourceDownloaded => 'Downloaded';
|
String get librarySourceDownloaded => 'ダウンロード済み';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get librarySourceLocal => 'Local';
|
String get librarySourceLocal => 'ローカル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterAll => 'All';
|
String get libraryFilterAll => 'すべて';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterDownloaded => 'Downloaded';
|
String get libraryFilterDownloaded => 'ダウンロード済み';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterLocal => 'Local';
|
String get libraryFilterLocal => 'ローカル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterTitle => 'Filters';
|
String get libraryFilterTitle => 'フィルター';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterReset => 'Reset';
|
String get libraryFilterReset => 'リセット';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterApply => 'Apply';
|
String get libraryFilterApply => '適用';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSource => 'Source';
|
String get libraryFilterSource => 'ソース';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterQuality => 'Quality';
|
String get libraryFilterQuality => '品質';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
String get libraryFilterQualityHiRes => 'ハイレゾ (24bit)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||||
@@ -1747,7 +1765,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get libraryFilterQualityLossy => 'Lossy';
|
String get libraryFilterQualityLossy => 'Lossy';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => '形式';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
@@ -1766,8 +1784,8 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count minutes ago',
|
other: '$count 分前',
|
||||||
one: '1 minute ago',
|
one: '1 分前',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1777,14 +1795,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count hours ago',
|
other: '$count 時間前',
|
||||||
one: '1 hour ago',
|
one: '1 時間前',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
String get tutorialWelcomeTitle => 'SpotiFLAC へようこそ!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeDesc =>
|
String get tutorialWelcomeDesc =>
|
||||||
@@ -1810,14 +1828,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'There are two easy ways to find music you want to download.';
|
'There are two easy ways to find music you want to download.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTitle => 'Downloading Music';
|
String get tutorialDownloadTitle => '音楽をダウンロード中';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadDesc =>
|
String get tutorialDownloadDesc =>
|
||||||
'Downloading music is simple and fast. Here\'s how it works.';
|
'Downloading music is simple and fast. Here\'s how it works.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTitle => 'Your Library';
|
String get tutorialLibraryTitle => 'あなたのライブラリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryDesc =>
|
String get tutorialLibraryDesc =>
|
||||||
@@ -1836,7 +1854,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Switch between list and grid view for better browsing';
|
'Switch between list and grid view for better browsing';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTitle => 'Extensions';
|
String get tutorialExtensionsTitle => '拡張';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsDesc =>
|
String get tutorialExtensionsDesc =>
|
||||||
@@ -1877,7 +1895,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'You\'re all set! Start downloading your favorite music now.';
|
'You\'re all set! Start downloading your favorite music now.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryForceFullScan => 'Force Full Scan';
|
String get libraryForceFullScan => '強制フルスキャン';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||||
@@ -1898,10 +1916,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTitle => 'Storage & Cache';
|
String get cacheTitle => 'ストレージとキャッシュ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSummaryTitle => 'Cache overview';
|
String get cacheSummaryTitle => 'キャッシュの概要';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSummarySubtitle =>
|
String get cacheSummarySubtitle =>
|
||||||
@@ -1913,34 +1931,34 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSectionStorage => 'Cached Data';
|
String get cacheSectionStorage => 'キャッシュ済みデータ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSectionMaintenance => 'Maintenance';
|
String get cacheSectionMaintenance => 'メンテナンス';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectory => 'App cache directory';
|
String get cacheAppDirectory => 'アプリキャッシュのディレクトリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectoryDesc =>
|
String get cacheAppDirectoryDesc =>
|
||||||
'HTTP responses, WebView data, and other temporary app data.';
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectory => 'Temporary directory';
|
String get cacheTempDirectory => '一時ディレクトリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectoryDesc =>
|
String get cacheTempDirectoryDesc =>
|
||||||
'Temporary files from downloads and audio conversion.';
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCoverImage => 'Cover image cache';
|
String get cacheCoverImage => 'カバー画像のキャッシュ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCoverImageDesc =>
|
String get cacheCoverImageDesc =>
|
||||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheLibraryCover => 'Library cover cache';
|
String get cacheLibraryCover => 'ライブラリのカバーキャッシュ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheLibraryCoverDesc =>
|
String get cacheLibraryCoverDesc =>
|
||||||
@@ -1965,7 +1983,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Remove orphaned download history and library entries for missing files.';
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheNoData => 'No cached data';
|
String get cacheNoData => 'キャッシュデータはありません';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheSizeWithFiles(String size, int count) {
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
@@ -1979,16 +1997,16 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheEntries(int count) {
|
String cacheEntries(int count) {
|
||||||
return '$count entries';
|
return '$count 個のエントリ';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheClearSuccess(String target) {
|
String cacheClearSuccess(String target) {
|
||||||
return 'Cleared: $target';
|
return '消去済み: $target';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
String get cacheClearConfirmTitle => 'キャッシュを消去しますか?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheClearConfirmMessage(String target) {
|
String cacheClearConfirmMessage(String target) {
|
||||||
@@ -1996,17 +2014,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
String get cacheClearAllConfirmTitle => 'すべてのキャッシュを消去しますか?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearAllConfirmMessage =>
|
String get cacheClearAllConfirmMessage =>
|
||||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearAll => 'Clear all cache';
|
String get cacheClearAll => 'すべてのキャッシュを消去';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
String get cacheCleanupUnused => '未使用のデータを削除';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnusedSubtitle =>
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
@@ -2018,16 +2036,16 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefreshStats => 'Refresh stats';
|
String get cacheRefreshStats => '状態を更新';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveCoverArt => 'Save Cover Art';
|
String get trackSaveCoverArt => 'カバー画像を保存';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
String get trackSaveLyrics => '歌詞を保存 (.lrc)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
@@ -2043,7 +2061,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'メタデータを編集';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackCoverSaved(String fileName) {
|
String trackCoverSaved(String fileName) {
|
||||||
@@ -2072,26 +2090,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return '失敗: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => '変換の形式';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle => 'MP3 または Opus に変換';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'オーディオを変換';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTargetFormat => 'Target Format';
|
String get trackConvertTargetFormat => 'ターゲットの形式';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertBitrate => 'Bitrate';
|
String get trackConvertBitrate => 'ビットレート';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
String get trackConvertConfirmTitle => '変換を確認';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackConvertConfirmMessage(
|
String trackConvertConfirmMessage(
|
||||||
@@ -2103,7 +2121,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'オーディオを変換中...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackConvertSuccess(String format) {
|
String trackConvertSuccess(String format) {
|
||||||
@@ -2111,7 +2129,55 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => '変換に失敗しました';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|||||||
+285
-238
File diff suppressed because it is too large
Load Diff
@@ -688,6 +688,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No tracks found';
|
String get errorNoTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Cannot load $item: missing extension source';
|
return 'Cannot load $item: missing extension source';
|
||||||
@@ -761,6 +772,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'No organization';
|
String get folderOrganizationNone => 'No organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'By Artist';
|
String get folderOrganizationByArtist => 'By Artist';
|
||||||
|
|
||||||
@@ -2126,6 +2144,54 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Albums';
|
String get artistAlbums => 'Albums';
|
||||||
@@ -688,6 +688,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No tracks found';
|
String get errorNoTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Cannot load $item: missing extension source';
|
return 'Cannot load $item: missing extension source';
|
||||||
@@ -761,6 +772,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'No organization';
|
String get folderOrganizationNone => 'No organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'By Artist';
|
String get folderOrganizationByArtist => 'By Artist';
|
||||||
|
|
||||||
@@ -1809,7 +1827,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2126,6 +2144,54 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
@@ -2705,7 +2771,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.';
|
'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Álbuns';
|
String get artistAlbums => 'Álbuns';
|
||||||
@@ -4147,7 +4213,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
|
|||||||
+182
-107
@@ -67,7 +67,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get settingsAbout => 'О программе';
|
String get settingsAbout => 'О программе';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadTitle => 'Скачивание';
|
String get downloadTitle => 'Скачать';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQualitySubtitle =>
|
String get downloadAskQualitySubtitle =>
|
||||||
@@ -146,11 +146,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'Использование только встроенных провайдеров';
|
'Использование только встроенных провайдеров';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsEmbedLyrics => 'Вставить текст песни';
|
String get optionsEmbedLyrics => 'Вписать текст песни';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsEmbedLyricsSubtitle =>
|
String get optionsEmbedLyricsSubtitle =>
|
||||||
'Вставить синхронизированные тексты в FLAC файлы';
|
'Вписать синхронизированные тексты во FLAC файлы';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCover => 'Максимальное качество обложки';
|
String get optionsMaxQualityCover => 'Максимальное качество обложки';
|
||||||
@@ -337,7 +337,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutBinimumDesc =>
|
String get aboutBinimumDesc =>
|
||||||
'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!';
|
'Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSachinsenalDesc =>
|
String get aboutSachinsenalDesc =>
|
||||||
@@ -601,7 +601,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String csvImportTracks(int count) {
|
String csvImportTracks(int count) {
|
||||||
return '$count треков из CSV';
|
return '$count трек(-ов) из CSV';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -702,6 +702,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'Треки не найдены';
|
String get errorNoTracksFound => 'Треки не найдены';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Невозможно загрузить $item: отсутствует источник расширения';
|
return 'Невозможно загрузить $item: отсутствует источник расширения';
|
||||||
@@ -766,15 +777,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get filenameFormat => 'Формат имени файла';
|
String get filenameFormat => 'Формат имени файла';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
String get filenameShowAdvancedTags => 'Показать расширенные теги';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTagsDescription =>
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
'Enable formatted tags for track padding and date patterns';
|
'Включить форматированные теги для отслеживания заполнения и шаблонов дат';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'Без организации';
|
String get folderOrganizationNone => 'Без организации';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'По исполнителю';
|
String get folderOrganizationByArtist => 'По исполнителю';
|
||||||
|
|
||||||
@@ -983,7 +1001,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'Выберите как сохранить тексты песен при скачивании';
|
'Выберите как сохранить тексты песен при скачивании';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeEmbed => 'Вставить в файл';
|
String get lyricsModeEmbed => 'Вписать в файл';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
|
String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
|
||||||
@@ -999,7 +1017,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get lyricsModeBoth => 'Оба варианта';
|
String get lyricsModeBoth => 'Оба варианта';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc';
|
String get lyricsModeBothSubtitle => 'Вписать и сохранить .lrc файл';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Цвет';
|
String get sectionColor => 'Цвет';
|
||||||
@@ -1138,7 +1156,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
|
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEmbedLyrics => 'Вставить текст песни';
|
String get trackEmbedLyrics => 'Вписать текст песни';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsEmbedded => 'Текст успешно добавлен';
|
String get trackLyricsEmbedded => 'Текст успешно добавлен';
|
||||||
@@ -1361,10 +1379,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
'YouTube обеспечивает только звук с потерями(Lossy).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||||
@@ -1383,7 +1401,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'Использовать исполнителя альбома для папок';
|
'Использовать исполнителя альбома для папок';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
String get downloadUsePrimaryArtistOnly =>
|
||||||
|
'Основной исполнитель только для папок';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
@@ -1391,7 +1410,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
'Full artist string used for folder name';
|
'Полная строка исполнителя, используемая для имени папки';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectQuality => 'Выбор качества';
|
String get downloadSelectQuality => 'Выбор качества';
|
||||||
@@ -1423,7 +1442,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get settingsDownloadNetwork => 'Сеть для скачивания';
|
String get settingsDownloadNetwork => 'Сеть для скачивания';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть';
|
String get settingsDownloadNetworkAny => 'WiFi и Мобильная сеть';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
|
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
|
||||||
@@ -1712,8 +1731,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'tracks',
|
other: 'треков',
|
||||||
one: 'track',
|
many: 'треков',
|
||||||
|
few: 'трека',
|
||||||
|
one: 'трек',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1800,7 +1821,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get libraryFilterQualityCD => 'CD (16 бит)';
|
String get libraryFilterQualityCD => 'CD (16 бит)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterQualityLossy => 'С потерями';
|
String get libraryFilterQualityLossy => 'Lossy';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Формат';
|
String get libraryFilterFormat => 'Формат';
|
||||||
@@ -1904,7 +1925,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Просмотрите вкладку Магазина, чтобы найти полезные расширения';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -1912,14 +1933,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip3 =>
|
String get tutorialExtensionsTip3 =>
|
||||||
'Get lyrics, enhanced metadata, and more features';
|
'Получайте тексты песен, улучшенные метаданные и другие возможности';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTitle => 'Настройте приложение под себя';
|
String get tutorialSettingsTitle => 'Настройте приложение под себя';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsDesc =>
|
String get tutorialSettingsDesc =>
|
||||||
'Personalize the app in Settings to match your preferences.';
|
'Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip1 =>
|
String get tutorialSettingsTip1 =>
|
||||||
@@ -1944,11 +1965,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'Пересканировать все файлы, игнорировать кэш';
|
'Пересканировать все файлы, игнорировать кэш';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
String get cleanupOrphanedDownloads => 'Очистка отложенных скачиваний';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsSubtitle =>
|
String get cleanupOrphanedDownloadsSubtitle =>
|
||||||
'Remove history entries for files that no longer exist';
|
'Удалить историю записи для файлов, которых больше не существует';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cleanupOrphanedDownloadsResult(int count) {
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
@@ -1956,7 +1977,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'Записей без описания не найдено';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTitle => 'Хранилище и кэш';
|
String get cacheTitle => 'Хранилище и кэш';
|
||||||
@@ -1966,11 +1987,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSummarySubtitle =>
|
String get cacheSummarySubtitle =>
|
||||||
'Clearing cache will not remove downloaded music files.';
|
'Очистка кэша не приведет к удалению загруженных музыкальных файлов.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheEstimatedTotal(String size) {
|
String cacheEstimatedTotal(String size) {
|
||||||
return 'Estimated cache usage: $size';
|
return 'Приблизительное использование кэша: $size';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1984,42 +2005,42 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectoryDesc =>
|
String get cacheAppDirectoryDesc =>
|
||||||
'HTTP responses, WebView data, and other temporary app data.';
|
'HTTP-ответы, данные WebView и другие временные данные приложения.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectory => 'Temporary directory';
|
String get cacheTempDirectory => 'Временная директория';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectoryDesc =>
|
String get cacheTempDirectoryDesc =>
|
||||||
'Temporary files from downloads and audio conversion.';
|
'Временные файлы из загрузок и аудио конвертации.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCoverImage => 'Cover image cache';
|
String get cacheCoverImage => 'Кэш обложек';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCoverImageDesc =>
|
String get cacheCoverImageDesc =>
|
||||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
'Скачанный альбом и трек обложки. Будет заново скачан после просмотра.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheLibraryCover => 'Library cover cache';
|
String get cacheLibraryCover => 'Кэш обложек библиотеки';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheLibraryCoverDesc =>
|
String get cacheLibraryCoverDesc =>
|
||||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
'Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheExploreFeed => 'Explore feed cache';
|
String get cacheExploreFeed => 'Просмотреть кэш ленты';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheExploreFeedDesc =>
|
String get cacheExploreFeedDesc =>
|
||||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
'Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTrackLookup => 'Track lookup cache';
|
String get cacheTrackLookup => 'Отслеживать кэш поиска';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTrackLookupDesc =>
|
String get cacheTrackLookupDesc =>
|
||||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
'Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnusedDesc =>
|
String get cacheCleanupUnusedDesc =>
|
||||||
@@ -2040,7 +2061,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheEntries(int count) {
|
String cacheEntries(int count) {
|
||||||
return '$count entries';
|
return '$count записей';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2053,7 +2074,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheClearConfirmMessage(String target) {
|
String cacheClearConfirmMessage(String target) {
|
||||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
return 'Это очистит кэш для $target. Загруженные музыкальные файлы не будут удалены.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2075,7 +2096,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
return 'Очистка завершена: $downloadCount потерянных загрузок, $libraryCount отсутствующих записей в библиотеке';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2095,14 +2116,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'Получить и сохранить текст песни в формате .lrc';
|
'Получить и сохранить текст песни в формате .lrc';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
String get trackSaveLyricsProgress => 'Сохранение текста...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich';
|
String get trackReEnrich => 'Обновить';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Поиск в сети метаданных и встраивание в файл';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Редактировать метаданные';
|
String get trackEditMetadata => 'Редактировать метаданные';
|
||||||
@@ -2121,13 +2142,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
String get trackReEnrichProgress => 'Обновление метаданных...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
|
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
String get trackReEnrichSuccess => 'Метаданные успешно обновлены';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed =>
|
String get trackReEnrichFfmpegFailed =>
|
||||||
@@ -2139,22 +2160,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Переконвертировать формат';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Конвертировать аудио';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTargetFormat => 'Target Format';
|
String get trackConvertTargetFormat => 'Целевой формат';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertBitrate => 'Bitrate';
|
String get trackConvertBitrate => 'Битрейт';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
String get trackConvertConfirmTitle => 'Подтвердить конвертацию';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackConvertConfirmMessage(
|
String trackConvertConfirmMessage(
|
||||||
@@ -2162,177 +2183,229 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String targetFormat,
|
String targetFormat,
|
||||||
String bitrate,
|
String bitrate,
|
||||||
) {
|
) {
|
||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Конвертация аудио...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackConvertSuccess(String format) {
|
String trackConvertSuccess(String format) {
|
||||||
return 'Converted to $format successfully';
|
return 'Успешно конвертировано в $format';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Ошибка конвертации';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionFoldersTitle => 'My folders';
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionWishlist => 'Wishlist';
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionLoved => 'Loved';
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylists => 'Playlists';
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylist => 'Playlist';
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionAddToPlaylist => 'Add to playlist';
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionCreatePlaylist => 'Create playlist';
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionCreate => 'Создать';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionFoldersTitle => 'Мои папки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionWishlist => 'Список желаемого';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionLoved => 'Любимые';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylists => 'Плейлисты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylist => 'Плейлист';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionAddToPlaylist => 'Добавить в плейлист';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionCreatePlaylist => 'Создать плейлист';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionNoPlaylistsYet => 'Плейлисты отсутствуют';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionNoPlaylistsSubtitle =>
|
String get collectionNoPlaylistsSubtitle =>
|
||||||
'Create a playlist to start categorizing tracks';
|
'Создайте плейлист, чтобы начать классифицировать треки';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionPlaylistTracks(int count) {
|
String collectionPlaylistTracks(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count tracks',
|
other: '$count треков',
|
||||||
one: '1 track',
|
many: '$count треков',
|
||||||
|
few: '$count трека',
|
||||||
|
one: '$count трек',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAddedToPlaylist(String playlistName) {
|
String collectionAddedToPlaylist(String playlistName) {
|
||||||
return 'Added to \"$playlistName\"';
|
return 'Добавлено в \"$playlistName\"';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAlreadyInPlaylist(String playlistName) {
|
String collectionAlreadyInPlaylist(String playlistName) {
|
||||||
return 'Already in \"$playlistName\"';
|
return 'Уже в \"$playlistName\"';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistCreated => 'Playlist created';
|
String get collectionPlaylistCreated => 'Плейлист создан';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistNameHint => 'Playlist name';
|
String get collectionPlaylistNameHint => 'Название плейлиста';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
String get collectionPlaylistNameRequired => 'Имя плейлиста обязательно';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionRenamePlaylist => 'Rename playlist';
|
String get collectionRenamePlaylist => 'Переименовать плейлист';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionDeletePlaylist => 'Delete playlist';
|
String get collectionDeletePlaylist => 'Удалить плейлист';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionDeletePlaylistMessage(String playlistName) {
|
String collectionDeletePlaylistMessage(String playlistName) {
|
||||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
return 'Удалить \"$playlistName\" и все треки внутри него?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
String get collectionPlaylistDeleted => 'Плейлист удалён';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
String get collectionPlaylistRenamed => 'Плейлист переименован';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
String get collectionWishlistEmptyTitle => 'Список желаний пуст';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionWishlistEmptySubtitle =>
|
String get collectionWishlistEmptySubtitle =>
|
||||||
'Tap + on tracks to save what you want to download later';
|
'Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
String get collectionLovedEmptyTitle => 'Папка Любимые пуста';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionLovedEmptySubtitle =>
|
String get collectionLovedEmptySubtitle =>
|
||||||
'Tap love on tracks to keep your favorites';
|
'Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
String get collectionPlaylistEmptyTitle => 'Плейлист пуст';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistEmptySubtitle =>
|
String get collectionPlaylistEmptySubtitle =>
|
||||||
'Long-press + on any track to add it here';
|
'Удерживайте + на любом треке, чтобы добавить его сюда';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
String get collectionRemoveFromPlaylist => 'Удалить из плейлиста';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
String get collectionRemoveFromFolder => 'Убрать из папки';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionRemoved(String trackName) {
|
String collectionRemoved(String trackName) {
|
||||||
return '\"$trackName\" removed';
|
return '\"$trackName\" удалён';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAddedToLoved(String trackName) {
|
String collectionAddedToLoved(String trackName) {
|
||||||
return '\"$trackName\" added to Loved';
|
return '\"$trackName\" добавлен в Любимые';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionRemovedFromLoved(String trackName) {
|
String collectionRemovedFromLoved(String trackName) {
|
||||||
return '\"$trackName\" removed from Loved';
|
return '\"$trackName\" удалено из Любимых';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionAddedToWishlist(String trackName) {
|
String collectionAddedToWishlist(String trackName) {
|
||||||
return '\"$trackName\" added to Wishlist';
|
return '\"$trackName\" добавлен в список желаний';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionRemovedFromWishlist(String trackName) {
|
String collectionRemovedFromWishlist(String trackName) {
|
||||||
return '\"$trackName\" removed from Wishlist';
|
return '\"$trackName\" удалён из списка желаний';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionAddToLoved => 'Add to Loved';
|
String get trackOptionAddToLoved => 'Добавить в Любимое';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
String get trackOptionRemoveFromLoved => 'Исключить из Любимых';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
String get trackOptionAddToWishlist => 'Добавить в список желаний';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
String get trackOptionRemoveFromWishlist => 'Удалить из списка желаний';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
String get collectionPlaylistChangeCover => 'Изменить обложку';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
String get collectionPlaylistRemoveCover => 'Удалить обложку';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionShareCount(int count) {
|
String selectionShareCount(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'tracks',
|
other: 'треков',
|
||||||
one: 'track',
|
many: 'треков',
|
||||||
|
few: 'трека',
|
||||||
|
one: 'трек',
|
||||||
);
|
);
|
||||||
return 'Share $count $_temp0';
|
return 'Отправить $count $_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2343,17 +2416,19 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'tracks',
|
other: 'треков',
|
||||||
one: 'track',
|
many: 'треков',
|
||||||
|
few: 'трека',
|
||||||
|
one: 'трек',
|
||||||
);
|
);
|
||||||
return 'Convert $count $_temp0';
|
return 'Конвертировать $count $_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
String get selectionConvertNoConvertible => 'Не выбраны конвертируемые треки';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
String get selectionBatchConvertConfirmTitle => 'Пакетная конвертация';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertConfirmMessage(
|
String selectionBatchConvertConfirmMessage(
|
||||||
@@ -2372,12 +2447,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Конвертация $current из $total...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
return 'Converted $success of $total tracks to $format';
|
return 'Конвертировано $success треков $total в $format';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2387,7 +2462,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
'Artist folders use Album Artist when available';
|
'Для папок исполнителей используется исполнитель альбома, если он указан';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
|
'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Albümler';
|
String get artistAlbums => 'Albümler';
|
||||||
@@ -693,6 +693,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'Parça bulunamadı';
|
String get errorNoTracksFound => 'Parça bulunamadı';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return '$item yüklenemedi: Eksik eklenti kaynağı';
|
return '$item yüklenemedi: Eksik eklenti kaynağı';
|
||||||
@@ -766,6 +777,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'Organizasyon yok';
|
String get folderOrganizationNone => 'Organizasyon yok';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'Sanatçıya Göre';
|
String get folderOrganizationByArtist => 'Sanatçıya Göre';
|
||||||
|
|
||||||
@@ -1821,7 +1839,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2138,6 +2156,54 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Create';
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
|||||||
+2634
-2112
File diff suppressed because it is too large
Load Diff
+583
-281
File diff suppressed because it is too large
Load Diff
+107
-3
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "By Artist",
|
"folderOrganizationByArtist": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -2383,7 +2403,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2828,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Create",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
@@ -402,7 +402,7 @@
|
|||||||
"@aboutDabMusicDesc": {
|
"@aboutDabMusicDesc": {
|
||||||
"description": "Credit for DAB Music API"
|
"description": "Credit for DAB Music API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1005,7 +1005,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
|
|||||||
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.",
|
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1089,7 +1089,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Integrado",
|
"providerBuiltIn": "Integrado",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extensión",
|
"providerExtension": "Extensión",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -2358,7 +2358,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
|
|||||||
+303
-1
@@ -991,6 +991,14 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "No organization",
|
"folderOrganizationNone": "No organization",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
@@ -1749,6 +1757,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2214,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2783,6 +2808,283 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3102,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+303
-1
@@ -991,6 +991,14 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "No organization",
|
"folderOrganizationNone": "No organization",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
@@ -1749,6 +1757,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2214,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2783,6 +2808,283 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3102,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+80
-68
@@ -9,7 +9,7 @@
|
|||||||
"@navHome": {
|
"@navHome": {
|
||||||
"description": "Bottom navigation - Home tab"
|
"description": "Bottom navigation - Home tab"
|
||||||
},
|
},
|
||||||
"navLibrary": "Library",
|
"navLibrary": "Pustaka",
|
||||||
"@navLibrary": {
|
"@navLibrary": {
|
||||||
"description": "Bottom navigation - Library tab"
|
"description": "Bottom navigation - Library tab"
|
||||||
},
|
},
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"@historyFilterSingles": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"description": "Filter chip - show singles only"
|
||||||
},
|
},
|
||||||
"historySearchHint": "Search history...",
|
"historySearchHint": "Cari riwayat...",
|
||||||
"@historySearchHint": {
|
"@historySearchHint": {
|
||||||
"description": "Search bar placeholder in history"
|
"description": "Search bar placeholder in history"
|
||||||
},
|
},
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"@appearanceHistoryViewList": {
|
"@appearanceHistoryViewList": {
|
||||||
"description": "List layout option"
|
"description": "List layout option"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewGrid": "Grid",
|
"appearanceHistoryViewGrid": "Kisi",
|
||||||
"@appearanceHistoryViewGrid": {
|
"@appearanceHistoryViewGrid": {
|
||||||
"description": "Grid layout option"
|
"description": "Grid layout option"
|
||||||
},
|
},
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back to built-in providers"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "Auto Fallback",
|
"optionsAutoFallback": "Cadangan Otomatis",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
"description": "Auto-retry with other services"
|
"description": "Auto-retry with other services"
|
||||||
},
|
},
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
"@optionsSpotifyCredentials": {
|
"@optionsSpotifyCredentials": {
|
||||||
"description": "Spotify API credentials setting"
|
"description": "Spotify API credentials setting"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
"optionsSpotifyCredentialsConfigured": "ID Klien: {clientId}...",
|
||||||
"@optionsSpotifyCredentialsConfigured": {
|
"@optionsSpotifyCredentialsConfigured": {
|
||||||
"description": "Shows configured client ID preview",
|
"description": "Shows configured client ID preview",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
"@optionsSpotifyWarning": {
|
"@optionsSpotifyWarning": {
|
||||||
"description": "Info about Spotify API requirement"
|
"description": "Info about Spotify API requirement"
|
||||||
},
|
},
|
||||||
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
|
"optionsSpotifyDeprecationWarning": "Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.",
|
||||||
"@optionsSpotifyDeprecationWarning": {
|
"@optionsSpotifyDeprecationWarning": {
|
||||||
"description": "Warning about Spotify API deprecation"
|
"description": "Warning about Spotify API deprecation"
|
||||||
},
|
},
|
||||||
@@ -358,7 +358,7 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
"aboutTranslators": "Translators",
|
"aboutTranslators": "Penerjemah",
|
||||||
"@aboutTranslators": {
|
"@aboutTranslators": {
|
||||||
"description": "Section for translators"
|
"description": "Section for translators"
|
||||||
},
|
},
|
||||||
@@ -394,23 +394,23 @@
|
|||||||
"@aboutFeatureRequestSubtitle": {
|
"@aboutFeatureRequestSubtitle": {
|
||||||
"description": "Subtitle for feature request"
|
"description": "Subtitle for feature request"
|
||||||
},
|
},
|
||||||
"aboutTelegramChannel": "Telegram Channel",
|
"aboutTelegramChannel": "Saluran Telegram",
|
||||||
"@aboutTelegramChannel": {
|
"@aboutTelegramChannel": {
|
||||||
"description": "Link to Telegram channel"
|
"description": "Link to Telegram channel"
|
||||||
},
|
},
|
||||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
"aboutTelegramChannelSubtitle": "Pengumuman dan pembaruan",
|
||||||
"@aboutTelegramChannelSubtitle": {
|
"@aboutTelegramChannelSubtitle": {
|
||||||
"description": "Subtitle for Telegram channel"
|
"description": "Subtitle for Telegram channel"
|
||||||
},
|
},
|
||||||
"aboutTelegramChat": "Telegram Community",
|
"aboutTelegramChat": "Komunitas Telegram",
|
||||||
"@aboutTelegramChat": {
|
"@aboutTelegramChat": {
|
||||||
"description": "Link to Telegram chat group"
|
"description": "Link to Telegram chat group"
|
||||||
},
|
},
|
||||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
"aboutTelegramChatSubtitle": "Berbincang dengan pengguna lain",
|
||||||
"@aboutTelegramChatSubtitle": {
|
"@aboutTelegramChatSubtitle": {
|
||||||
"description": "Subtitle for Telegram chat"
|
"description": "Subtitle for Telegram chat"
|
||||||
},
|
},
|
||||||
"aboutSocial": "Social",
|
"aboutSocial": "Sosial",
|
||||||
"@aboutSocial": {
|
"@aboutSocial": {
|
||||||
"description": "Section for social links"
|
"description": "Section for social links"
|
||||||
},
|
},
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
"@aboutSachinsenalDesc": {
|
"@aboutSachinsenalDesc": {
|
||||||
"description": "Credit description for sachinsenal0x64"
|
"description": "Credit description for sachinsenal0x64"
|
||||||
},
|
},
|
||||||
"aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!",
|
"aboutSjdonadoDesc": "Pencipta I Don't Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!",
|
||||||
"@aboutSjdonadoDesc": {
|
"@aboutSjdonadoDesc": {
|
||||||
"description": "Credit description for sjdonado"
|
"description": "Credit description for sjdonado"
|
||||||
},
|
},
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
"@aboutSpotiSaver": {
|
"@aboutSpotiSaver": {
|
||||||
"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"
|
"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
|
"aboutSpotiSaverDesc": "Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!",
|
||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
@@ -579,7 +579,7 @@
|
|||||||
"@setupIosEmptyFolderWarning": {
|
"@setupIosEmptyFolderWarning": {
|
||||||
"description": "iOS folder selection warning"
|
"description": "iOS folder selection warning"
|
||||||
},
|
},
|
||||||
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
|
"setupIcloudNotSupported": "iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.",
|
||||||
"@setupIcloudNotSupported": {
|
"@setupIcloudNotSupported": {
|
||||||
"description": "Error when user selects iCloud Drive on iOS"
|
"description": "Error when user selects iCloud Drive on iOS"
|
||||||
},
|
},
|
||||||
@@ -742,7 +742,7 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
|
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
|
||||||
"csvImportTracks": "{count} tracks from CSV",
|
"csvImportTracks": "{count} trek dari CSV",
|
||||||
"@csvImportTracks": {
|
"@csvImportTracks": {
|
||||||
"description": "Label shown in quality picker for CSV import",
|
"description": "Label shown in quality picker for CSV import",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -786,7 +786,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
|
"snackbarAlreadyInLibrary": "\"{trackName}\" sudah ada di perpustakaan Anda",
|
||||||
"@snackbarAlreadyInLibrary": {
|
"@snackbarAlreadyInLibrary": {
|
||||||
"description": "Snackbar - track already exists in local library",
|
"description": "Snackbar - track already exists in local library",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link tidak dikenali",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Gagal memuat konten dari link ini. Silakan coba lagi.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
|
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -991,11 +1003,11 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
"@filenameShowAdvancedTags": {
|
"@filenameShowAdvancedTags": {
|
||||||
"description": "Toggle label for showing advanced filename tags"
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTagsDescription": "Aktifkan tag format untuk padding nomor lagu dan pola tanggal",
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
"@filenameShowAdvancedTagsDescription": {
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
"description": "Description for advanced filename tag toggle"
|
"description": "Description for advanced filename tag toggle"
|
||||||
},
|
},
|
||||||
@@ -1757,11 +1769,11 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
"youtubeOpusBitrateTitle": "Bitrate Opus YouTube",
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
"@youtubeOpusBitrateTitle": {
|
"@youtubeOpusBitrateTitle": {
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
},
|
},
|
||||||
"youtubeMp3BitrateTitle": "Bitrate MP3 YouTube",
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
"@youtubeMp3BitrateTitle": {
|
"@youtubeMp3BitrateTitle": {
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
},
|
},
|
||||||
@@ -2214,7 +2226,7 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
"libraryTracksUnit": "{count, plural, =1{trek} other{trek}}",
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
"@libraryTracksUnit": {
|
"@libraryTracksUnit": {
|
||||||
"description": "Unit label for tracks count (without the number itself)",
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2808,11 +2820,11 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
"actionCreate": "Buat",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
},
|
},
|
||||||
"collectionFoldersTitle": "Folder saya",
|
"collectionFoldersTitle": "My folders",
|
||||||
"@collectionFoldersTitle": {
|
"@collectionFoldersTitle": {
|
||||||
"description": "Library section title for custom folders"
|
"description": "Library section title for custom folders"
|
||||||
},
|
},
|
||||||
@@ -2824,7 +2836,7 @@
|
|||||||
"@collectionLoved": {
|
"@collectionLoved": {
|
||||||
"description": "Custom folder for favorite tracks"
|
"description": "Custom folder for favorite tracks"
|
||||||
},
|
},
|
||||||
"collectionPlaylists": "Playlist",
|
"collectionPlaylists": "Playlists",
|
||||||
"@collectionPlaylists": {
|
"@collectionPlaylists": {
|
||||||
"description": "Custom user playlists folder"
|
"description": "Custom user playlists folder"
|
||||||
},
|
},
|
||||||
@@ -2832,23 +2844,23 @@
|
|||||||
"@collectionPlaylist": {
|
"@collectionPlaylist": {
|
||||||
"description": "Single playlist label"
|
"description": "Single playlist label"
|
||||||
},
|
},
|
||||||
"collectionAddToPlaylist": "Tambahkan ke playlist",
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
"@collectionAddToPlaylist": {
|
"@collectionAddToPlaylist": {
|
||||||
"description": "Action to add a track to user playlist"
|
"description": "Action to add a track to user playlist"
|
||||||
},
|
},
|
||||||
"collectionCreatePlaylist": "Buat playlist",
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
"@collectionCreatePlaylist": {
|
"@collectionCreatePlaylist": {
|
||||||
"description": "Action to create a new playlist"
|
"description": "Action to create a new playlist"
|
||||||
},
|
},
|
||||||
"collectionNoPlaylistsYet": "Belum ada playlist",
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
"@collectionNoPlaylistsYet": {
|
"@collectionNoPlaylistsYet": {
|
||||||
"description": "Empty state title when user has no playlists"
|
"description": "Empty state title when user has no playlists"
|
||||||
},
|
},
|
||||||
"collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu",
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
"@collectionNoPlaylistsSubtitle": {
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
"description": "Empty state subtitle when user has no playlists"
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
},
|
},
|
||||||
"collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
"@collectionPlaylistTracks": {
|
"@collectionPlaylistTracks": {
|
||||||
"description": "Track count label for custom playlists",
|
"description": "Track count label for custom playlists",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2857,7 +2869,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"",
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
"@collectionAddedToPlaylist": {
|
"@collectionAddedToPlaylist": {
|
||||||
"description": "Snackbar after adding track to playlist",
|
"description": "Snackbar after adding track to playlist",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2866,7 +2878,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"",
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
"@collectionAlreadyInPlaylist": {
|
"@collectionAlreadyInPlaylist": {
|
||||||
"description": "Snackbar when track already exists in playlist",
|
"description": "Snackbar when track already exists in playlist",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2875,27 +2887,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionPlaylistCreated": "Playlist berhasil dibuat",
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
"@collectionPlaylistCreated": {
|
"@collectionPlaylistCreated": {
|
||||||
"description": "Snackbar after creating playlist"
|
"description": "Snackbar after creating playlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistNameHint": "Nama playlist",
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
"@collectionPlaylistNameHint": {
|
"@collectionPlaylistNameHint": {
|
||||||
"description": "Hint text for playlist name input"
|
"description": "Hint text for playlist name input"
|
||||||
},
|
},
|
||||||
"collectionPlaylistNameRequired": "Nama playlist wajib diisi",
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
"@collectionPlaylistNameRequired": {
|
"@collectionPlaylistNameRequired": {
|
||||||
"description": "Validation error for empty playlist name"
|
"description": "Validation error for empty playlist name"
|
||||||
},
|
},
|
||||||
"collectionRenamePlaylist": "Ubah nama playlist",
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
"@collectionRenamePlaylist": {
|
"@collectionRenamePlaylist": {
|
||||||
"description": "Action to rename playlist"
|
"description": "Action to rename playlist"
|
||||||
},
|
},
|
||||||
"collectionDeletePlaylist": "Hapus playlist",
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
"@collectionDeletePlaylist": {
|
"@collectionDeletePlaylist": {
|
||||||
"description": "Action to delete playlist"
|
"description": "Action to delete playlist"
|
||||||
},
|
},
|
||||||
"collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?",
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
"@collectionDeletePlaylistMessage": {
|
"@collectionDeletePlaylistMessage": {
|
||||||
"description": "Confirmation message for deleting playlist",
|
"description": "Confirmation message for deleting playlist",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2904,47 +2916,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionPlaylistDeleted": "Playlist dihapus",
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
"@collectionPlaylistDeleted": {
|
"@collectionPlaylistDeleted": {
|
||||||
"description": "Snackbar after deleting playlist"
|
"description": "Snackbar after deleting playlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistRenamed": "Nama playlist diperbarui",
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
"@collectionPlaylistRenamed": {
|
"@collectionPlaylistRenamed": {
|
||||||
"description": "Snackbar after renaming playlist"
|
"description": "Snackbar after renaming playlist"
|
||||||
},
|
},
|
||||||
"collectionWishlistEmptyTitle": "Wishlist masih kosong",
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
"@collectionWishlistEmptyTitle": {
|
"@collectionWishlistEmptyTitle": {
|
||||||
"description": "Wishlist empty state title"
|
"description": "Wishlist empty state title"
|
||||||
},
|
},
|
||||||
"collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti",
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
"@collectionWishlistEmptySubtitle": {
|
"@collectionWishlistEmptySubtitle": {
|
||||||
"description": "Wishlist empty state subtitle"
|
"description": "Wishlist empty state subtitle"
|
||||||
},
|
},
|
||||||
"collectionLovedEmptyTitle": "Folder Loved masih kosong",
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
"@collectionLovedEmptyTitle": {
|
"@collectionLovedEmptyTitle": {
|
||||||
"description": "Loved empty state title"
|
"description": "Loved empty state title"
|
||||||
},
|
},
|
||||||
"collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu",
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
"@collectionLovedEmptySubtitle": {
|
"@collectionLovedEmptySubtitle": {
|
||||||
"description": "Loved empty state subtitle"
|
"description": "Loved empty state subtitle"
|
||||||
},
|
},
|
||||||
"collectionPlaylistEmptyTitle": "Playlist masih kosong",
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
"@collectionPlaylistEmptyTitle": {
|
"@collectionPlaylistEmptyTitle": {
|
||||||
"description": "Playlist empty state title"
|
"description": "Playlist empty state title"
|
||||||
},
|
},
|
||||||
"collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini",
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
"@collectionPlaylistEmptySubtitle": {
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
"description": "Playlist empty state subtitle"
|
"description": "Playlist empty state subtitle"
|
||||||
},
|
},
|
||||||
"collectionRemoveFromPlaylist": "Hapus dari playlist",
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
"@collectionRemoveFromPlaylist": {
|
"@collectionRemoveFromPlaylist": {
|
||||||
"description": "Tooltip for removing track from playlist"
|
"description": "Tooltip for removing track from playlist"
|
||||||
},
|
},
|
||||||
"collectionRemoveFromFolder": "Hapus dari folder",
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
"@collectionRemoveFromFolder": {
|
"@collectionRemoveFromFolder": {
|
||||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
},
|
},
|
||||||
"collectionRemoved": "\"{trackName}\" dihapus",
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
"@collectionRemoved": {
|
"@collectionRemoved": {
|
||||||
"description": "Snackbar after removing a track from a collection",
|
"description": "Snackbar after removing a track from a collection",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2953,7 +2965,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved",
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
"@collectionAddedToLoved": {
|
"@collectionAddedToLoved": {
|
||||||
"description": "Snackbar after adding track to loved folder",
|
"description": "Snackbar after adding track to loved folder",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2962,7 +2974,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved",
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
"@collectionRemovedFromLoved": {
|
"@collectionRemovedFromLoved": {
|
||||||
"description": "Snackbar after removing track from loved folder",
|
"description": "Snackbar after removing track from loved folder",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2971,7 +2983,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist",
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
"@collectionAddedToWishlist": {
|
"@collectionAddedToWishlist": {
|
||||||
"description": "Snackbar after adding track to wishlist",
|
"description": "Snackbar after adding track to wishlist",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2980,7 +2992,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist",
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
"@collectionRemovedFromWishlist": {
|
"@collectionRemovedFromWishlist": {
|
||||||
"description": "Snackbar after removing track from wishlist",
|
"description": "Snackbar after removing track from wishlist",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2989,31 +3001,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackOptionAddToLoved": "Tambahkan ke Loved",
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
"@trackOptionAddToLoved": {
|
"@trackOptionAddToLoved": {
|
||||||
"description": "Bottom sheet action label - add track to loved folder"
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
},
|
},
|
||||||
"trackOptionRemoveFromLoved": "Hapus dari Loved",
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
"@trackOptionRemoveFromLoved": {
|
"@trackOptionRemoveFromLoved": {
|
||||||
"description": "Bottom sheet action label - remove track from loved folder"
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
},
|
},
|
||||||
"trackOptionAddToWishlist": "Tambahkan ke Wishlist",
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
"@trackOptionAddToWishlist": {
|
"@trackOptionAddToWishlist": {
|
||||||
"description": "Bottom sheet action label - add track to wishlist"
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
},
|
},
|
||||||
"trackOptionRemoveFromWishlist": "Hapus dari Wishlist",
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
"@trackOptionRemoveFromWishlist": {
|
"@trackOptionRemoveFromWishlist": {
|
||||||
"description": "Bottom sheet action label - remove track from wishlist"
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistChangeCover": "Ubah gambar sampul",
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
"@collectionPlaylistChangeCover": {
|
"@collectionPlaylistChangeCover": {
|
||||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistRemoveCover": "Hapus gambar sampul",
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
"@collectionPlaylistRemoveCover": {
|
"@collectionPlaylistRemoveCover": {
|
||||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
},
|
},
|
||||||
"selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}",
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
"@selectionShareCount": {
|
"@selectionShareCount": {
|
||||||
"description": "Share button text with count in selection mode",
|
"description": "Share button text with count in selection mode",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3022,11 +3034,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectionShareNoFiles": "Tidak ada file yang dapat dibagikan",
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
"@selectionShareNoFiles": {
|
"@selectionShareNoFiles": {
|
||||||
"description": "Snackbar when no selected files exist on disk"
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
},
|
},
|
||||||
"selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}",
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
"@selectionConvertCount": {
|
"@selectionConvertCount": {
|
||||||
"description": "Convert button text with count in selection mode",
|
"description": "Convert button text with count in selection mode",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3035,15 +3047,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih",
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
"@selectionConvertNoConvertible": {
|
"@selectionConvertNoConvertible": {
|
||||||
"description": "Snackbar when no selected tracks support conversion"
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
},
|
},
|
||||||
"selectionBatchConvertConfirmTitle": "Konversi Massal",
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
"@selectionBatchConvertConfirmTitle": {
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title for batch conversion"
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
},
|
},
|
||||||
"selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
"@selectionBatchConvertConfirmMessage": {
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
"description": "Confirmation dialog message for batch conversion",
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3058,7 +3070,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectionBatchConvertProgress": "Mengonversi {current} dari {total}...",
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
"@selectionBatchConvertProgress": {
|
"@selectionBatchConvertProgress": {
|
||||||
"description": "Snackbar during batch conversion progress",
|
"description": "Snackbar during batch conversion progress",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3070,7 +3082,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}",
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
"@selectionBatchConvertSuccess": {
|
"@selectionBatchConvertSuccess": {
|
||||||
"description": "Snackbar after batch conversion completes",
|
"description": "Snackbar after batch conversion completes",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3102,4 +3114,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+378
-76
@@ -9,7 +9,7 @@
|
|||||||
"@navHome": {
|
"@navHome": {
|
||||||
"description": "Bottom navigation - Home tab"
|
"description": "Bottom navigation - Home tab"
|
||||||
},
|
},
|
||||||
"navLibrary": "Library",
|
"navLibrary": "ライブラリ",
|
||||||
"@navLibrary": {
|
"@navLibrary": {
|
||||||
"description": "Bottom navigation - Library tab"
|
"description": "Bottom navigation - Library tab"
|
||||||
},
|
},
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
"@optionsConcurrentSequential": {
|
"@optionsConcurrentSequential": {
|
||||||
"description": "Download one at a time"
|
"description": "Download one at a time"
|
||||||
},
|
},
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
"optionsConcurrentParallel": "{count} 件の分割ダウンロード",
|
||||||
"@optionsConcurrentParallel": {
|
"@optionsConcurrentParallel": {
|
||||||
"description": "Multiple parallel downloads",
|
"description": "Multiple parallel downloads",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -991,6 +991,14 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "構成がありません",
|
"folderOrganizationNone": "構成がありません",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
@@ -1455,7 +1463,7 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
"trackEmbedLyrics": "Embed Lyrics",
|
"trackEmbedLyrics": "歌詞を埋め込む",
|
||||||
"@trackEmbedLyrics": {
|
"@trackEmbedLyrics": {
|
||||||
"description": "Action - embed lyrics into audio file"
|
"description": "Action - embed lyrics into audio file"
|
||||||
},
|
},
|
||||||
@@ -1749,6 +1757,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -1805,7 +1821,7 @@
|
|||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
"settingsAutoExportFailed": "ダウンロードの自動エクスポートに失敗しました",
|
||||||
"@settingsAutoExportFailed": {
|
"@settingsAutoExportFailed": {
|
||||||
"description": "Setting toggle for auto-export"
|
"description": "Setting toggle for auto-export"
|
||||||
},
|
},
|
||||||
@@ -1813,15 +1829,15 @@
|
|||||||
"@settingsAutoExportFailedSubtitle": {
|
"@settingsAutoExportFailedSubtitle": {
|
||||||
"description": "Subtitle for auto-export setting"
|
"description": "Subtitle for auto-export setting"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetwork": "Download Network",
|
"settingsDownloadNetwork": "ダウンロードネットワーク",
|
||||||
"@settingsDownloadNetwork": {
|
"@settingsDownloadNetwork": {
|
||||||
"description": "Setting for network type preference"
|
"description": "Setting for network type preference"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
"settingsDownloadNetworkAny": "Wi-Fi + モバイルデータ",
|
||||||
"@settingsDownloadNetworkAny": {
|
"@settingsDownloadNetworkAny": {
|
||||||
"description": "Network option - use any connection"
|
"description": "Network option - use any connection"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
"settingsDownloadNetworkWifiOnly": "Wi-Fi のみ",
|
||||||
"@settingsDownloadNetworkWifiOnly": {
|
"@settingsDownloadNetworkWifiOnly": {
|
||||||
"description": "Network option - only use WiFi"
|
"description": "Network option - only use WiFi"
|
||||||
},
|
},
|
||||||
@@ -1861,7 +1877,7 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
"albumFolderArtistAlbumSingles": "アーティスト / アルバム + シングル",
|
||||||
"@albumFolderArtistAlbumSingles": {
|
"@albumFolderArtistAlbumSingles": {
|
||||||
"description": "Album folder option with singles inside artist"
|
"description": "Album folder option with singles inside artist"
|
||||||
},
|
},
|
||||||
@@ -1942,7 +1958,7 @@
|
|||||||
"@recentEmpty": {
|
"@recentEmpty": {
|
||||||
"description": "Empty state text for recent access list"
|
"description": "Empty state text for recent access list"
|
||||||
},
|
},
|
||||||
"recentShowAllDownloads": "Show All Downloads",
|
"recentShowAllDownloads": "すべてのダウンロードを表示",
|
||||||
"@recentShowAllDownloads": {
|
"@recentShowAllDownloads": {
|
||||||
"description": "Button label to unhide hidden downloads in recent access"
|
"description": "Button label to unhide hidden downloads in recent access"
|
||||||
},
|
},
|
||||||
@@ -2074,11 +2090,11 @@
|
|||||||
"@discographyFailedToFetch": {
|
"@discographyFailedToFetch": {
|
||||||
"description": "Error - some albums failed to load"
|
"description": "Error - some albums failed to load"
|
||||||
},
|
},
|
||||||
"sectionStorageAccess": "Storage Access",
|
"sectionStorageAccess": "ストレージアクセス",
|
||||||
"@sectionStorageAccess": {
|
"@sectionStorageAccess": {
|
||||||
"description": "Section header for storage access settings"
|
"description": "Section header for storage access settings"
|
||||||
},
|
},
|
||||||
"allFilesAccess": "All Files Access",
|
"allFilesAccess": "すべてのファイルへのアクセス",
|
||||||
"@allFilesAccess": {
|
"@allFilesAccess": {
|
||||||
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
||||||
},
|
},
|
||||||
@@ -2102,7 +2118,7 @@
|
|||||||
"@allFilesAccessDisabledMessage": {
|
"@allFilesAccessDisabledMessage": {
|
||||||
"description": "Snackbar message when user disables all files access"
|
"description": "Snackbar message when user disables all files access"
|
||||||
},
|
},
|
||||||
"settingsLocalLibrary": "Local Library",
|
"settingsLocalLibrary": "ローカルライブラリ",
|
||||||
"@settingsLocalLibrary": {
|
"@settingsLocalLibrary": {
|
||||||
"description": "Settings menu item - local library"
|
"description": "Settings menu item - local library"
|
||||||
},
|
},
|
||||||
@@ -2110,7 +2126,7 @@
|
|||||||
"@settingsLocalLibrarySubtitle": {
|
"@settingsLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for local library settings"
|
"description": "Subtitle for local library settings"
|
||||||
},
|
},
|
||||||
"settingsCache": "Storage & Cache",
|
"settingsCache": "ストレージとキャッシュ",
|
||||||
"@settingsCache": {
|
"@settingsCache": {
|
||||||
"description": "Settings menu item - cache management"
|
"description": "Settings menu item - cache management"
|
||||||
},
|
},
|
||||||
@@ -2118,15 +2134,15 @@
|
|||||||
"@settingsCacheSubtitle": {
|
"@settingsCacheSubtitle": {
|
||||||
"description": "Subtitle for cache management menu"
|
"description": "Subtitle for cache management menu"
|
||||||
},
|
},
|
||||||
"libraryTitle": "Local Library",
|
"libraryTitle": "ローカルライブラリ",
|
||||||
"@libraryTitle": {
|
"@libraryTitle": {
|
||||||
"description": "Library settings page title"
|
"description": "Library settings page title"
|
||||||
},
|
},
|
||||||
"libraryScanSettings": "Scan Settings",
|
"libraryScanSettings": "スキャン設定",
|
||||||
"@libraryScanSettings": {
|
"@libraryScanSettings": {
|
||||||
"description": "Section header for scan settings"
|
"description": "Section header for scan settings"
|
||||||
},
|
},
|
||||||
"libraryEnableLocalLibrary": "Enable Local Library",
|
"libraryEnableLocalLibrary": "ローカルライブラリを有効",
|
||||||
"@libraryEnableLocalLibrary": {
|
"@libraryEnableLocalLibrary": {
|
||||||
"description": "Toggle to enable library scanning"
|
"description": "Toggle to enable library scanning"
|
||||||
},
|
},
|
||||||
@@ -2134,11 +2150,11 @@
|
|||||||
"@libraryEnableLocalLibrarySubtitle": {
|
"@libraryEnableLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for enable toggle"
|
"description": "Subtitle for enable toggle"
|
||||||
},
|
},
|
||||||
"libraryFolder": "Library Folder",
|
"libraryFolder": "ライブラリのフォルダ",
|
||||||
"@libraryFolder": {
|
"@libraryFolder": {
|
||||||
"description": "Folder selection setting"
|
"description": "Folder selection setting"
|
||||||
},
|
},
|
||||||
"libraryFolderHint": "Tap to select folder",
|
"libraryFolderHint": "タップでフォルダを選択",
|
||||||
"@libraryFolderHint": {
|
"@libraryFolderHint": {
|
||||||
"description": "Placeholder when no folder selected"
|
"description": "Placeholder when no folder selected"
|
||||||
},
|
},
|
||||||
@@ -2150,15 +2166,15 @@
|
|||||||
"@libraryShowDuplicateIndicatorSubtitle": {
|
"@libraryShowDuplicateIndicatorSubtitle": {
|
||||||
"description": "Subtitle for duplicate indicator toggle"
|
"description": "Subtitle for duplicate indicator toggle"
|
||||||
},
|
},
|
||||||
"libraryActions": "Actions",
|
"libraryActions": "アクション",
|
||||||
"@libraryActions": {
|
"@libraryActions": {
|
||||||
"description": "Section header for library actions"
|
"description": "Section header for library actions"
|
||||||
},
|
},
|
||||||
"libraryScan": "Scan Library",
|
"libraryScan": "ライブラリをスキャン",
|
||||||
"@libraryScan": {
|
"@libraryScan": {
|
||||||
"description": "Button to start library scan"
|
"description": "Button to start library scan"
|
||||||
},
|
},
|
||||||
"libraryScanSubtitle": "Scan for audio files",
|
"libraryScanSubtitle": "オーディオファイルをスキャン",
|
||||||
"@libraryScanSubtitle": {
|
"@libraryScanSubtitle": {
|
||||||
"description": "Subtitle for scan button"
|
"description": "Subtitle for scan button"
|
||||||
},
|
},
|
||||||
@@ -2174,7 +2190,7 @@
|
|||||||
"@libraryCleanupMissingFilesSubtitle": {
|
"@libraryCleanupMissingFilesSubtitle": {
|
||||||
"description": "Subtitle for cleanup button"
|
"description": "Subtitle for cleanup button"
|
||||||
},
|
},
|
||||||
"libraryClear": "Clear Library",
|
"libraryClear": "ライブラリを消去",
|
||||||
"@libraryClear": {
|
"@libraryClear": {
|
||||||
"description": "Button to clear all library entries"
|
"description": "Button to clear all library entries"
|
||||||
},
|
},
|
||||||
@@ -2182,7 +2198,7 @@
|
|||||||
"@libraryClearSubtitle": {
|
"@libraryClearSubtitle": {
|
||||||
"description": "Subtitle for clear button"
|
"description": "Subtitle for clear button"
|
||||||
},
|
},
|
||||||
"libraryClearConfirmTitle": "Clear Library",
|
"libraryClearConfirmTitle": "ライブラリを消去",
|
||||||
"@libraryClearConfirmTitle": {
|
"@libraryClearConfirmTitle": {
|
||||||
"description": "Dialog title for clear confirmation"
|
"description": "Dialog title for clear confirmation"
|
||||||
},
|
},
|
||||||
@@ -2190,7 +2206,7 @@
|
|||||||
"@libraryClearConfirmMessage": {
|
"@libraryClearConfirmMessage": {
|
||||||
"description": "Dialog message for clear confirmation"
|
"description": "Dialog message for clear confirmation"
|
||||||
},
|
},
|
||||||
"libraryAbout": "About Local Library",
|
"libraryAbout": "ローカルライブラリについて",
|
||||||
"@libraryAbout": {
|
"@libraryAbout": {
|
||||||
"description": "Section header for about info"
|
"description": "Section header for about info"
|
||||||
},
|
},
|
||||||
@@ -2198,7 +2214,16 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraryLastScanned": "最終スキャン: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2211,7 +2236,7 @@
|
|||||||
"@libraryLastScannedNever": {
|
"@libraryLastScannedNever": {
|
||||||
"description": "Shown when library has never been scanned"
|
"description": "Shown when library has never been scanned"
|
||||||
},
|
},
|
||||||
"libraryScanning": "Scanning...",
|
"libraryScanning": "スキャン中...",
|
||||||
"@libraryScanning": {
|
"@libraryScanning": {
|
||||||
"description": "Status during scan"
|
"description": "Status during scan"
|
||||||
},
|
},
|
||||||
@@ -2227,7 +2252,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"libraryInLibrary": "In Library",
|
"libraryInLibrary": "ライブラリ内",
|
||||||
"@libraryInLibrary": {
|
"@libraryInLibrary": {
|
||||||
"description": "Badge shown on tracks that exist in local library"
|
"description": "Badge shown on tracks that exist in local library"
|
||||||
},
|
},
|
||||||
@@ -2244,7 +2269,7 @@
|
|||||||
"@libraryCleared": {
|
"@libraryCleared": {
|
||||||
"description": "Snackbar after clearing library"
|
"description": "Snackbar after clearing library"
|
||||||
},
|
},
|
||||||
"libraryStorageAccessRequired": "Storage Access Required",
|
"libraryStorageAccessRequired": "ストレージアクセスが必要です",
|
||||||
"@libraryStorageAccessRequired": {
|
"@libraryStorageAccessRequired": {
|
||||||
"description": "Dialog title for storage permission"
|
"description": "Dialog title for storage permission"
|
||||||
},
|
},
|
||||||
@@ -2256,47 +2281,47 @@
|
|||||||
"@libraryFolderNotExist": {
|
"@libraryFolderNotExist": {
|
||||||
"description": "Error when folder doesn't exist"
|
"description": "Error when folder doesn't exist"
|
||||||
},
|
},
|
||||||
"librarySourceDownloaded": "Downloaded",
|
"librarySourceDownloaded": "ダウンロード済み",
|
||||||
"@librarySourceDownloaded": {
|
"@librarySourceDownloaded": {
|
||||||
"description": "Badge for tracks downloaded via SpotiFLAC"
|
"description": "Badge for tracks downloaded via SpotiFLAC"
|
||||||
},
|
},
|
||||||
"librarySourceLocal": "Local",
|
"librarySourceLocal": "ローカル",
|
||||||
"@librarySourceLocal": {
|
"@librarySourceLocal": {
|
||||||
"description": "Badge for tracks from local library scan"
|
"description": "Badge for tracks from local library scan"
|
||||||
},
|
},
|
||||||
"libraryFilterAll": "All",
|
"libraryFilterAll": "すべて",
|
||||||
"@libraryFilterAll": {
|
"@libraryFilterAll": {
|
||||||
"description": "Filter chip - show all library items"
|
"description": "Filter chip - show all library items"
|
||||||
},
|
},
|
||||||
"libraryFilterDownloaded": "Downloaded",
|
"libraryFilterDownloaded": "ダウンロード済み",
|
||||||
"@libraryFilterDownloaded": {
|
"@libraryFilterDownloaded": {
|
||||||
"description": "Filter chip - show only downloaded items"
|
"description": "Filter chip - show only downloaded items"
|
||||||
},
|
},
|
||||||
"libraryFilterLocal": "Local",
|
"libraryFilterLocal": "ローカル",
|
||||||
"@libraryFilterLocal": {
|
"@libraryFilterLocal": {
|
||||||
"description": "Filter chip - show only local library items"
|
"description": "Filter chip - show only local library items"
|
||||||
},
|
},
|
||||||
"libraryFilterTitle": "Filters",
|
"libraryFilterTitle": "フィルター",
|
||||||
"@libraryFilterTitle": {
|
"@libraryFilterTitle": {
|
||||||
"description": "Filter bottom sheet title"
|
"description": "Filter bottom sheet title"
|
||||||
},
|
},
|
||||||
"libraryFilterReset": "Reset",
|
"libraryFilterReset": "リセット",
|
||||||
"@libraryFilterReset": {
|
"@libraryFilterReset": {
|
||||||
"description": "Reset all filters button"
|
"description": "Reset all filters button"
|
||||||
},
|
},
|
||||||
"libraryFilterApply": "Apply",
|
"libraryFilterApply": "適用",
|
||||||
"@libraryFilterApply": {
|
"@libraryFilterApply": {
|
||||||
"description": "Apply filters button"
|
"description": "Apply filters button"
|
||||||
},
|
},
|
||||||
"libraryFilterSource": "Source",
|
"libraryFilterSource": "ソース",
|
||||||
"@libraryFilterSource": {
|
"@libraryFilterSource": {
|
||||||
"description": "Filter section - source type"
|
"description": "Filter section - source type"
|
||||||
},
|
},
|
||||||
"libraryFilterQuality": "Quality",
|
"libraryFilterQuality": "品質",
|
||||||
"@libraryFilterQuality": {
|
"@libraryFilterQuality": {
|
||||||
"description": "Filter section - audio quality"
|
"description": "Filter section - audio quality"
|
||||||
},
|
},
|
||||||
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
|
"libraryFilterQualityHiRes": "ハイレゾ (24bit)",
|
||||||
"@libraryFilterQualityHiRes": {
|
"@libraryFilterQualityHiRes": {
|
||||||
"description": "Filter option - high resolution audio"
|
"description": "Filter option - high resolution audio"
|
||||||
},
|
},
|
||||||
@@ -2308,7 +2333,7 @@
|
|||||||
"@libraryFilterQualityLossy": {
|
"@libraryFilterQualityLossy": {
|
||||||
"description": "Filter option - lossy compressed audio"
|
"description": "Filter option - lossy compressed audio"
|
||||||
},
|
},
|
||||||
"libraryFilterFormat": "Format",
|
"libraryFilterFormat": "形式",
|
||||||
"@libraryFilterFormat": {
|
"@libraryFilterFormat": {
|
||||||
"description": "Filter section - file format"
|
"description": "Filter section - file format"
|
||||||
},
|
},
|
||||||
@@ -2328,7 +2353,7 @@
|
|||||||
"@timeJustNow": {
|
"@timeJustNow": {
|
||||||
"description": "Relative time - less than a minute ago"
|
"description": "Relative time - less than a minute ago"
|
||||||
},
|
},
|
||||||
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
|
"timeMinutesAgo": "{count, plural, =1{1 分前} other{{count} 分前}}",
|
||||||
"@timeMinutesAgo": {
|
"@timeMinutesAgo": {
|
||||||
"description": "Relative time - minutes ago",
|
"description": "Relative time - minutes ago",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2337,7 +2362,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
|
"timeHoursAgo": "{count, plural, =1{1 時間前} other{{count} 時間前}}",
|
||||||
"@timeHoursAgo": {
|
"@timeHoursAgo": {
|
||||||
"description": "Relative time - hours ago",
|
"description": "Relative time - hours ago",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2346,7 +2371,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
"tutorialWelcomeTitle": "SpotiFLAC へようこそ!",
|
||||||
"@tutorialWelcomeTitle": {
|
"@tutorialWelcomeTitle": {
|
||||||
"description": "Tutorial welcome page title"
|
"description": "Tutorial welcome page title"
|
||||||
},
|
},
|
||||||
@@ -2374,7 +2399,7 @@
|
|||||||
"@tutorialSearchDesc": {
|
"@tutorialSearchDesc": {
|
||||||
"description": "Tutorial search page description"
|
"description": "Tutorial search page description"
|
||||||
},
|
},
|
||||||
"tutorialDownloadTitle": "Downloading Music",
|
"tutorialDownloadTitle": "音楽をダウンロード中",
|
||||||
"@tutorialDownloadTitle": {
|
"@tutorialDownloadTitle": {
|
||||||
"description": "Tutorial download page title"
|
"description": "Tutorial download page title"
|
||||||
},
|
},
|
||||||
@@ -2382,7 +2407,7 @@
|
|||||||
"@tutorialDownloadDesc": {
|
"@tutorialDownloadDesc": {
|
||||||
"description": "Tutorial download page description"
|
"description": "Tutorial download page description"
|
||||||
},
|
},
|
||||||
"tutorialLibraryTitle": "Your Library",
|
"tutorialLibraryTitle": "あなたのライブラリ",
|
||||||
"@tutorialLibraryTitle": {
|
"@tutorialLibraryTitle": {
|
||||||
"description": "Tutorial library page title"
|
"description": "Tutorial library page title"
|
||||||
},
|
},
|
||||||
@@ -2402,7 +2427,7 @@
|
|||||||
"@tutorialLibraryTip3": {
|
"@tutorialLibraryTip3": {
|
||||||
"description": "Tutorial library tip 3"
|
"description": "Tutorial library tip 3"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTitle": "Extensions",
|
"tutorialExtensionsTitle": "拡張",
|
||||||
"@tutorialExtensionsTitle": {
|
"@tutorialExtensionsTitle": {
|
||||||
"description": "Tutorial extensions page title"
|
"description": "Tutorial extensions page title"
|
||||||
},
|
},
|
||||||
@@ -2446,7 +2471,7 @@
|
|||||||
"@tutorialReadyMessage": {
|
"@tutorialReadyMessage": {
|
||||||
"description": "Tutorial completion message"
|
"description": "Tutorial completion message"
|
||||||
},
|
},
|
||||||
"libraryForceFullScan": "Force Full Scan",
|
"libraryForceFullScan": "強制フルスキャン",
|
||||||
"@libraryForceFullScan": {
|
"@libraryForceFullScan": {
|
||||||
"description": "Button to force a complete rescan of library"
|
"description": "Button to force a complete rescan of library"
|
||||||
},
|
},
|
||||||
@@ -2475,11 +2500,11 @@
|
|||||||
"@cleanupOrphanedDownloadsNone": {
|
"@cleanupOrphanedDownloadsNone": {
|
||||||
"description": "Snackbar when no orphans found"
|
"description": "Snackbar when no orphans found"
|
||||||
},
|
},
|
||||||
"cacheTitle": "Storage & Cache",
|
"cacheTitle": "ストレージとキャッシュ",
|
||||||
"@cacheTitle": {
|
"@cacheTitle": {
|
||||||
"description": "Cache management page title"
|
"description": "Cache management page title"
|
||||||
},
|
},
|
||||||
"cacheSummaryTitle": "Cache overview",
|
"cacheSummaryTitle": "キャッシュの概要",
|
||||||
"@cacheSummaryTitle": {
|
"@cacheSummaryTitle": {
|
||||||
"description": "Heading for cache summary card"
|
"description": "Heading for cache summary card"
|
||||||
},
|
},
|
||||||
@@ -2496,15 +2521,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cacheSectionStorage": "Cached Data",
|
"cacheSectionStorage": "キャッシュ済みデータ",
|
||||||
"@cacheSectionStorage": {
|
"@cacheSectionStorage": {
|
||||||
"description": "Section header for cache entries"
|
"description": "Section header for cache entries"
|
||||||
},
|
},
|
||||||
"cacheSectionMaintenance": "Maintenance",
|
"cacheSectionMaintenance": "メンテナンス",
|
||||||
"@cacheSectionMaintenance": {
|
"@cacheSectionMaintenance": {
|
||||||
"description": "Section header for cleanup actions"
|
"description": "Section header for cleanup actions"
|
||||||
},
|
},
|
||||||
"cacheAppDirectory": "App cache directory",
|
"cacheAppDirectory": "アプリキャッシュのディレクトリ",
|
||||||
"@cacheAppDirectory": {
|
"@cacheAppDirectory": {
|
||||||
"description": "Cache item title for app cache directory"
|
"description": "Cache item title for app cache directory"
|
||||||
},
|
},
|
||||||
@@ -2512,7 +2537,7 @@
|
|||||||
"@cacheAppDirectoryDesc": {
|
"@cacheAppDirectoryDesc": {
|
||||||
"description": "Description of what app cache directory contains"
|
"description": "Description of what app cache directory contains"
|
||||||
},
|
},
|
||||||
"cacheTempDirectory": "Temporary directory",
|
"cacheTempDirectory": "一時ディレクトリ",
|
||||||
"@cacheTempDirectory": {
|
"@cacheTempDirectory": {
|
||||||
"description": "Cache item title for temporary files directory"
|
"description": "Cache item title for temporary files directory"
|
||||||
},
|
},
|
||||||
@@ -2520,7 +2545,7 @@
|
|||||||
"@cacheTempDirectoryDesc": {
|
"@cacheTempDirectoryDesc": {
|
||||||
"description": "Description of what temporary directory contains"
|
"description": "Description of what temporary directory contains"
|
||||||
},
|
},
|
||||||
"cacheCoverImage": "Cover image cache",
|
"cacheCoverImage": "カバー画像のキャッシュ",
|
||||||
"@cacheCoverImage": {
|
"@cacheCoverImage": {
|
||||||
"description": "Cache item title for persistent cover images"
|
"description": "Cache item title for persistent cover images"
|
||||||
},
|
},
|
||||||
@@ -2528,7 +2553,7 @@
|
|||||||
"@cacheCoverImageDesc": {
|
"@cacheCoverImageDesc": {
|
||||||
"description": "Description of what cover image cache contains"
|
"description": "Description of what cover image cache contains"
|
||||||
},
|
},
|
||||||
"cacheLibraryCover": "Library cover cache",
|
"cacheLibraryCover": "ライブラリのカバーキャッシュ",
|
||||||
"@cacheLibraryCover": {
|
"@cacheLibraryCover": {
|
||||||
"description": "Cache item title for local library cover art images"
|
"description": "Cache item title for local library cover art images"
|
||||||
},
|
},
|
||||||
@@ -2556,7 +2581,7 @@
|
|||||||
"@cacheCleanupUnusedDesc": {
|
"@cacheCleanupUnusedDesc": {
|
||||||
"description": "Description of what cleanup unused data does"
|
"description": "Description of what cleanup unused data does"
|
||||||
},
|
},
|
||||||
"cacheNoData": "No cached data",
|
"cacheNoData": "キャッシュデータはありません",
|
||||||
"@cacheNoData": {
|
"@cacheNoData": {
|
||||||
"description": "Label when cache category has no data"
|
"description": "Label when cache category has no data"
|
||||||
},
|
},
|
||||||
@@ -2581,7 +2606,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cacheEntries": "{count} entries",
|
"cacheEntries": "{count} 個のエントリ",
|
||||||
"@cacheEntries": {
|
"@cacheEntries": {
|
||||||
"description": "Track cache entry count",
|
"description": "Track cache entry count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2590,7 +2615,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cacheClearSuccess": "Cleared: {target}",
|
"cacheClearSuccess": "消去済み: {target}",
|
||||||
"@cacheClearSuccess": {
|
"@cacheClearSuccess": {
|
||||||
"description": "Snackbar after clearing selected cache",
|
"description": "Snackbar after clearing selected cache",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2599,7 +2624,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cacheClearConfirmTitle": "Clear cache?",
|
"cacheClearConfirmTitle": "キャッシュを消去しますか?",
|
||||||
"@cacheClearConfirmTitle": {
|
"@cacheClearConfirmTitle": {
|
||||||
"description": "Dialog title before clearing one cache category"
|
"description": "Dialog title before clearing one cache category"
|
||||||
},
|
},
|
||||||
@@ -2612,7 +2637,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cacheClearAllConfirmTitle": "Clear all cache?",
|
"cacheClearAllConfirmTitle": "すべてのキャッシュを消去しますか?",
|
||||||
"@cacheClearAllConfirmTitle": {
|
"@cacheClearAllConfirmTitle": {
|
||||||
"description": "Dialog title before clearing all caches"
|
"description": "Dialog title before clearing all caches"
|
||||||
},
|
},
|
||||||
@@ -2620,11 +2645,11 @@
|
|||||||
"@cacheClearAllConfirmMessage": {
|
"@cacheClearAllConfirmMessage": {
|
||||||
"description": "Dialog message before clearing all caches"
|
"description": "Dialog message before clearing all caches"
|
||||||
},
|
},
|
||||||
"cacheClearAll": "Clear all cache",
|
"cacheClearAll": "すべてのキャッシュを消去",
|
||||||
"@cacheClearAll": {
|
"@cacheClearAll": {
|
||||||
"description": "Button label to clear all caches"
|
"description": "Button label to clear all caches"
|
||||||
},
|
},
|
||||||
"cacheCleanupUnused": "Cleanup unused data",
|
"cacheCleanupUnused": "未使用のデータを削除",
|
||||||
"@cacheCleanupUnused": {
|
"@cacheCleanupUnused": {
|
||||||
"description": "Action title for cleaning unused entries"
|
"description": "Action title for cleaning unused entries"
|
||||||
},
|
},
|
||||||
@@ -2644,11 +2669,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cacheRefreshStats": "Refresh stats",
|
"cacheRefreshStats": "状態を更新",
|
||||||
"@cacheRefreshStats": {
|
"@cacheRefreshStats": {
|
||||||
"description": "Button label to refresh cache statistics"
|
"description": "Button label to refresh cache statistics"
|
||||||
},
|
},
|
||||||
"trackSaveCoverArt": "Save Cover Art",
|
"trackSaveCoverArt": "カバー画像を保存",
|
||||||
"@trackSaveCoverArt": {
|
"@trackSaveCoverArt": {
|
||||||
"description": "Menu action - save album cover art as file"
|
"description": "Menu action - save album cover art as file"
|
||||||
},
|
},
|
||||||
@@ -2656,7 +2681,7 @@
|
|||||||
"@trackSaveCoverArtSubtitle": {
|
"@trackSaveCoverArtSubtitle": {
|
||||||
"description": "Subtitle for save cover art action"
|
"description": "Subtitle for save cover art action"
|
||||||
},
|
},
|
||||||
"trackSaveLyrics": "Save Lyrics (.lrc)",
|
"trackSaveLyrics": "歌詞を保存 (.lrc)",
|
||||||
"@trackSaveLyrics": {
|
"@trackSaveLyrics": {
|
||||||
"description": "Menu action - save lyrics as .lrc file"
|
"description": "Menu action - save lyrics as .lrc file"
|
||||||
},
|
},
|
||||||
@@ -2676,7 +2701,7 @@
|
|||||||
"@trackReEnrichOnlineSubtitle": {
|
"@trackReEnrichOnlineSubtitle": {
|
||||||
"description": "Subtitle for re-enrich metadata action for local items"
|
"description": "Subtitle for re-enrich metadata action for local items"
|
||||||
},
|
},
|
||||||
"trackEditMetadata": "Edit Metadata",
|
"trackEditMetadata": "メタデータを編集",
|
||||||
"@trackEditMetadata": {
|
"@trackEditMetadata": {
|
||||||
"description": "Menu action - edit embedded metadata"
|
"description": "Menu action - edit embedded metadata"
|
||||||
},
|
},
|
||||||
@@ -2718,7 +2743,7 @@
|
|||||||
"@trackReEnrichFfmpegFailed": {
|
"@trackReEnrichFfmpegFailed": {
|
||||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||||
},
|
},
|
||||||
"trackSaveFailed": "Failed: {error}",
|
"trackSaveFailed": "失敗: {error}",
|
||||||
"@trackSaveFailed": {
|
"@trackSaveFailed": {
|
||||||
"description": "Snackbar when save operation fails",
|
"description": "Snackbar when save operation fails",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2727,27 +2752,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertFormat": "Convert Format",
|
"trackConvertFormat": "変換の形式",
|
||||||
"@trackConvertFormat": {
|
"@trackConvertFormat": {
|
||||||
"description": "Menu item - convert audio format"
|
"description": "Menu item - convert audio format"
|
||||||
},
|
},
|
||||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
"trackConvertFormatSubtitle": "MP3 または Opus に変換",
|
||||||
"@trackConvertFormatSubtitle": {
|
"@trackConvertFormatSubtitle": {
|
||||||
"description": "Subtitle for convert format menu item"
|
"description": "Subtitle for convert format menu item"
|
||||||
},
|
},
|
||||||
"trackConvertTitle": "Convert Audio",
|
"trackConvertTitle": "オーディオを変換",
|
||||||
"@trackConvertTitle": {
|
"@trackConvertTitle": {
|
||||||
"description": "Title of convert bottom sheet"
|
"description": "Title of convert bottom sheet"
|
||||||
},
|
},
|
||||||
"trackConvertTargetFormat": "Target Format",
|
"trackConvertTargetFormat": "ターゲットの形式",
|
||||||
"@trackConvertTargetFormat": {
|
"@trackConvertTargetFormat": {
|
||||||
"description": "Label for format selection"
|
"description": "Label for format selection"
|
||||||
},
|
},
|
||||||
"trackConvertBitrate": "Bitrate",
|
"trackConvertBitrate": "ビットレート",
|
||||||
"@trackConvertBitrate": {
|
"@trackConvertBitrate": {
|
||||||
"description": "Label for bitrate selection"
|
"description": "Label for bitrate selection"
|
||||||
},
|
},
|
||||||
"trackConvertConfirmTitle": "Confirm Conversion",
|
"trackConvertConfirmTitle": "変換を確認",
|
||||||
"@trackConvertConfirmTitle": {
|
"@trackConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title"
|
"description": "Confirmation dialog title"
|
||||||
},
|
},
|
||||||
@@ -2766,7 +2791,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertConverting": "Converting audio...",
|
"trackConvertConverting": "オーディオを変換中...",
|
||||||
"@trackConvertConverting": {
|
"@trackConvertConverting": {
|
||||||
"description": "Snackbar while converting"
|
"description": "Snackbar while converting"
|
||||||
},
|
},
|
||||||
@@ -2779,10 +2804,287 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertFailed": "Conversion failed",
|
"trackConvertFailed": "変換に失敗しました",
|
||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み",
|
"downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3102,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+520
-218
File diff suppressed because it is too large
Load Diff
+303
-1
@@ -991,6 +991,14 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "No organization",
|
"folderOrganizationNone": "No organization",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
@@ -1749,6 +1757,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2214,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2783,6 +2808,283 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3102,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
"@aboutDabMusicDesc": {
|
"@aboutDabMusicDesc": {
|
||||||
"description": "Credit for DAB Music API"
|
"description": "Credit for DAB Music API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1005,7 +1005,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
|
|||||||
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.",
|
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1089,7 +1089,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Embutido",
|
"providerBuiltIn": "Embutido",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extensão",
|
"providerExtension": "Extensão",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -2358,7 +2358,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
|
|||||||
+357
-55
@@ -77,7 +77,7 @@
|
|||||||
"@settingsAbout": {
|
"@settingsAbout": {
|
||||||
"description": "Settings section - app info"
|
"description": "Settings section - app info"
|
||||||
},
|
},
|
||||||
"downloadTitle": "Скачивание",
|
"downloadTitle": "Скачать",
|
||||||
"@downloadTitle": {
|
"@downloadTitle": {
|
||||||
"description": "Download settings page title"
|
"description": "Download settings page title"
|
||||||
},
|
},
|
||||||
@@ -174,11 +174,11 @@
|
|||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Status when extension providers disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Вставить текст песни",
|
"optionsEmbedLyrics": "Вписать текст песни",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
"description": "Embed lyrics in audio files"
|
"description": "Embed lyrics in audio files"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyricsSubtitle": "Вставить синхронизированные тексты в FLAC файлы",
|
"optionsEmbedLyricsSubtitle": "Вписать синхронизированные тексты во FLAC файлы",
|
||||||
"@optionsEmbedLyricsSubtitle": {
|
"@optionsEmbedLyricsSubtitle": {
|
||||||
"description": "Subtitle for embed lyrics"
|
"description": "Subtitle for embed lyrics"
|
||||||
},
|
},
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
"@aboutVersion": {
|
"@aboutVersion": {
|
||||||
"description": "Version info label"
|
"description": "Version info label"
|
||||||
},
|
},
|
||||||
"aboutBinimumDesc": "Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!",
|
"aboutBinimumDesc": "Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!",
|
||||||
"@aboutBinimumDesc": {
|
"@aboutBinimumDesc": {
|
||||||
"description": "Credit description for binimum"
|
"description": "Credit description for binimum"
|
||||||
},
|
},
|
||||||
@@ -728,7 +728,7 @@
|
|||||||
"@dialogDeleteSelectedTitle": {
|
"@dialogDeleteSelectedTitle": {
|
||||||
"description": "Dialog title - delete selected items"
|
"description": "Dialog title - delete selected items"
|
||||||
},
|
},
|
||||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||||
"@dialogDeleteSelectedMessage": {
|
"@dialogDeleteSelectedMessage": {
|
||||||
"description": "Dialog message - delete selected tracks",
|
"description": "Dialog message - delete selected tracks",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -742,7 +742,7 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
|
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
|
||||||
"csvImportTracks": "{count} треков из CSV",
|
"csvImportTracks": "{count} трек(-ов) из CSV",
|
||||||
"@csvImportTracks": {
|
"@csvImportTracks": {
|
||||||
"description": "Label shown in quality picker for CSV import",
|
"description": "Label shown in quality picker for CSV import",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -807,7 +807,7 @@
|
|||||||
"@snackbarCredentialsCleared": {
|
"@snackbarCredentialsCleared": {
|
||||||
"description": "Snackbar - Spotify credentials removed"
|
"description": "Snackbar - Spotify credentials removed"
|
||||||
},
|
},
|
||||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||||
"@snackbarDeletedTracks": {
|
"@snackbarDeletedTracks": {
|
||||||
"description": "Snackbar - tracks deleted",
|
"description": "Snackbar - tracks deleted",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -991,6 +991,14 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Показать расширенные теги",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Включить форматированные теги для отслеживания заполнения и шаблонов дат",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "Без организации",
|
"folderOrganizationNone": "Без организации",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
@@ -1261,7 +1269,7 @@
|
|||||||
"@lyricsModeDescription": {
|
"@lyricsModeDescription": {
|
||||||
"description": "Lyrics mode picker description"
|
"description": "Lyrics mode picker description"
|
||||||
},
|
},
|
||||||
"lyricsModeEmbed": "Вставить в файл",
|
"lyricsModeEmbed": "Вписать в файл",
|
||||||
"@lyricsModeEmbed": {
|
"@lyricsModeEmbed": {
|
||||||
"description": "Lyrics mode option - embed in audio file"
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
},
|
},
|
||||||
@@ -1281,7 +1289,7 @@
|
|||||||
"@lyricsModeBoth": {
|
"@lyricsModeBoth": {
|
||||||
"description": "Lyrics mode option - embed and external"
|
"description": "Lyrics mode option - embed and external"
|
||||||
},
|
},
|
||||||
"lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc",
|
"lyricsModeBothSubtitle": "Вписать и сохранить .lrc файл",
|
||||||
"@lyricsModeBothSubtitle": {
|
"@lyricsModeBothSubtitle": {
|
||||||
"description": "Subtitle for both option"
|
"description": "Subtitle for both option"
|
||||||
},
|
},
|
||||||
@@ -1455,7 +1463,7 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
"trackEmbedLyrics": "Вставить текст песни",
|
"trackEmbedLyrics": "Вписать текст песни",
|
||||||
"@trackEmbedLyrics": {
|
"@trackEmbedLyrics": {
|
||||||
"description": "Action - embed lyrics into audio file"
|
"description": "Action - embed lyrics into audio file"
|
||||||
},
|
},
|
||||||
@@ -1749,6 +1757,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -1769,7 +1785,7 @@
|
|||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
"downloadUsePrimaryArtistOnly": "Основной исполнитель только для папок",
|
||||||
"@downloadUsePrimaryArtistOnly": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"description": "Setting - strip featured artists from folder name"
|
||||||
},
|
},
|
||||||
@@ -1777,7 +1793,7 @@
|
|||||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"description": "Subtitle when primary artist only is enabled"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
"downloadUsePrimaryArtistOnlyDisabled": "Полная строка исполнителя, используемая для имени папки",
|
||||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||||
"description": "Subtitle when primary artist only is disabled"
|
"description": "Subtitle when primary artist only is disabled"
|
||||||
},
|
},
|
||||||
@@ -1817,7 +1833,7 @@
|
|||||||
"@settingsDownloadNetwork": {
|
"@settingsDownloadNetwork": {
|
||||||
"description": "Setting for network type preference"
|
"description": "Setting for network type preference"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkAny": "WiFi и мобильная сеть",
|
"settingsDownloadNetworkAny": "WiFi и Мобильная сеть",
|
||||||
"@settingsDownloadNetworkAny": {
|
"@settingsDownloadNetworkAny": {
|
||||||
"description": "Network option - use any connection"
|
"description": "Network option - use any connection"
|
||||||
},
|
},
|
||||||
@@ -1873,7 +1889,7 @@
|
|||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||||
"@downloadedAlbumDeleteMessage": {
|
"@downloadedAlbumDeleteMessage": {
|
||||||
"description": "Delete confirmation with count",
|
"description": "Delete confirmation with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1899,7 +1915,7 @@
|
|||||||
"@downloadedAlbumTapToSelect": {
|
"@downloadedAlbumTapToSelect": {
|
||||||
"description": "Selection hint"
|
"description": "Selection hint"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||||
"@downloadedAlbumDeleteCount": {
|
"@downloadedAlbumDeleteCount": {
|
||||||
"description": "Delete button text with count",
|
"description": "Delete button text with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2198,6 +2214,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Последнее сканирование: {time}",
|
"libraryLastScanned": "Последнее сканирование: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2304,7 +2329,7 @@
|
|||||||
"@libraryFilterQualityCD": {
|
"@libraryFilterQualityCD": {
|
||||||
"description": "Filter option - CD quality audio"
|
"description": "Filter option - CD quality audio"
|
||||||
},
|
},
|
||||||
"libraryFilterQualityLossy": "С потерями",
|
"libraryFilterQualityLossy": "Lossy",
|
||||||
"@libraryFilterQualityLossy": {
|
"@libraryFilterQualityLossy": {
|
||||||
"description": "Filter option - lossy compressed audio"
|
"description": "Filter option - lossy compressed audio"
|
||||||
},
|
},
|
||||||
@@ -2410,7 +2435,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Просмотрите вкладку Магазина, чтобы найти полезные расширения",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
@@ -2418,7 +2443,7 @@
|
|||||||
"@tutorialExtensionsTip2": {
|
"@tutorialExtensionsTip2": {
|
||||||
"description": "Tutorial extensions tip 2"
|
"description": "Tutorial extensions tip 2"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
|
"tutorialExtensionsTip3": "Получайте тексты песен, улучшенные метаданные и другие возможности",
|
||||||
"@tutorialExtensionsTip3": {
|
"@tutorialExtensionsTip3": {
|
||||||
"description": "Tutorial extensions tip 3"
|
"description": "Tutorial extensions tip 3"
|
||||||
},
|
},
|
||||||
@@ -2426,7 +2451,7 @@
|
|||||||
"@tutorialSettingsTitle": {
|
"@tutorialSettingsTitle": {
|
||||||
"description": "Tutorial settings page title"
|
"description": "Tutorial settings page title"
|
||||||
},
|
},
|
||||||
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
|
"tutorialSettingsDesc": "Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.",
|
||||||
"@tutorialSettingsDesc": {
|
"@tutorialSettingsDesc": {
|
||||||
"description": "Tutorial settings page description"
|
"description": "Tutorial settings page description"
|
||||||
},
|
},
|
||||||
@@ -2454,11 +2479,11 @@
|
|||||||
"@libraryForceFullScanSubtitle": {
|
"@libraryForceFullScanSubtitle": {
|
||||||
"description": "Subtitle for force full scan button"
|
"description": "Subtitle for force full scan button"
|
||||||
},
|
},
|
||||||
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
|
"cleanupOrphanedDownloads": "Очистка отложенных скачиваний",
|
||||||
"@cleanupOrphanedDownloads": {
|
"@cleanupOrphanedDownloads": {
|
||||||
"description": "Button to remove history entries for deleted files"
|
"description": "Button to remove history entries for deleted files"
|
||||||
},
|
},
|
||||||
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
|
"cleanupOrphanedDownloadsSubtitle": "Удалить историю записи для файлов, которых больше не существует",
|
||||||
"@cleanupOrphanedDownloadsSubtitle": {
|
"@cleanupOrphanedDownloadsSubtitle": {
|
||||||
"description": "Subtitle for orphaned cleanup button"
|
"description": "Subtitle for orphaned cleanup button"
|
||||||
},
|
},
|
||||||
@@ -2471,7 +2496,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
|
"cleanupOrphanedDownloadsNone": "Записей без описания не найдено",
|
||||||
"@cleanupOrphanedDownloadsNone": {
|
"@cleanupOrphanedDownloadsNone": {
|
||||||
"description": "Snackbar when no orphans found"
|
"description": "Snackbar when no orphans found"
|
||||||
},
|
},
|
||||||
@@ -2483,11 +2508,11 @@
|
|||||||
"@cacheSummaryTitle": {
|
"@cacheSummaryTitle": {
|
||||||
"description": "Heading for cache summary card"
|
"description": "Heading for cache summary card"
|
||||||
},
|
},
|
||||||
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
|
"cacheSummarySubtitle": "Очистка кэша не приведет к удалению загруженных музыкальных файлов.",
|
||||||
"@cacheSummarySubtitle": {
|
"@cacheSummarySubtitle": {
|
||||||
"description": "Helper text for cache summary card"
|
"description": "Helper text for cache summary card"
|
||||||
},
|
},
|
||||||
"cacheEstimatedTotal": "Estimated cache usage: {size}",
|
"cacheEstimatedTotal": "Приблизительное использование кэша: {size}",
|
||||||
"@cacheEstimatedTotal": {
|
"@cacheEstimatedTotal": {
|
||||||
"description": "Total cache size shown in summary",
|
"description": "Total cache size shown in summary",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2508,47 +2533,47 @@
|
|||||||
"@cacheAppDirectory": {
|
"@cacheAppDirectory": {
|
||||||
"description": "Cache item title for app cache directory"
|
"description": "Cache item title for app cache directory"
|
||||||
},
|
},
|
||||||
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
|
"cacheAppDirectoryDesc": "HTTP-ответы, данные WebView и другие временные данные приложения.",
|
||||||
"@cacheAppDirectoryDesc": {
|
"@cacheAppDirectoryDesc": {
|
||||||
"description": "Description of what app cache directory contains"
|
"description": "Description of what app cache directory contains"
|
||||||
},
|
},
|
||||||
"cacheTempDirectory": "Temporary directory",
|
"cacheTempDirectory": "Временная директория",
|
||||||
"@cacheTempDirectory": {
|
"@cacheTempDirectory": {
|
||||||
"description": "Cache item title for temporary files directory"
|
"description": "Cache item title for temporary files directory"
|
||||||
},
|
},
|
||||||
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
|
"cacheTempDirectoryDesc": "Временные файлы из загрузок и аудио конвертации.",
|
||||||
"@cacheTempDirectoryDesc": {
|
"@cacheTempDirectoryDesc": {
|
||||||
"description": "Description of what temporary directory contains"
|
"description": "Description of what temporary directory contains"
|
||||||
},
|
},
|
||||||
"cacheCoverImage": "Cover image cache",
|
"cacheCoverImage": "Кэш обложек",
|
||||||
"@cacheCoverImage": {
|
"@cacheCoverImage": {
|
||||||
"description": "Cache item title for persistent cover images"
|
"description": "Cache item title for persistent cover images"
|
||||||
},
|
},
|
||||||
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
|
"cacheCoverImageDesc": "Скачанный альбом и трек обложки. Будет заново скачан после просмотра.",
|
||||||
"@cacheCoverImageDesc": {
|
"@cacheCoverImageDesc": {
|
||||||
"description": "Description of what cover image cache contains"
|
"description": "Description of what cover image cache contains"
|
||||||
},
|
},
|
||||||
"cacheLibraryCover": "Library cover cache",
|
"cacheLibraryCover": "Кэш обложек библиотеки",
|
||||||
"@cacheLibraryCover": {
|
"@cacheLibraryCover": {
|
||||||
"description": "Cache item title for local library cover art images"
|
"description": "Cache item title for local library cover art images"
|
||||||
},
|
},
|
||||||
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
|
"cacheLibraryCoverDesc": "Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.",
|
||||||
"@cacheLibraryCoverDesc": {
|
"@cacheLibraryCoverDesc": {
|
||||||
"description": "Description of what library cover cache contains"
|
"description": "Description of what library cover cache contains"
|
||||||
},
|
},
|
||||||
"cacheExploreFeed": "Explore feed cache",
|
"cacheExploreFeed": "Просмотреть кэш ленты",
|
||||||
"@cacheExploreFeed": {
|
"@cacheExploreFeed": {
|
||||||
"description": "Cache item title for explore home feed cache"
|
"description": "Cache item title for explore home feed cache"
|
||||||
},
|
},
|
||||||
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
|
"cacheExploreFeedDesc": "Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.",
|
||||||
"@cacheExploreFeedDesc": {
|
"@cacheExploreFeedDesc": {
|
||||||
"description": "Description of what explore feed cache contains"
|
"description": "Description of what explore feed cache contains"
|
||||||
},
|
},
|
||||||
"cacheTrackLookup": "Track lookup cache",
|
"cacheTrackLookup": "Отслеживать кэш поиска",
|
||||||
"@cacheTrackLookup": {
|
"@cacheTrackLookup": {
|
||||||
"description": "Cache item title for track ID lookup cache"
|
"description": "Cache item title for track ID lookup cache"
|
||||||
},
|
},
|
||||||
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
|
"cacheTrackLookupDesc": "Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.",
|
||||||
"@cacheTrackLookupDesc": {
|
"@cacheTrackLookupDesc": {
|
||||||
"description": "Description of what track lookup cache contains"
|
"description": "Description of what track lookup cache contains"
|
||||||
},
|
},
|
||||||
@@ -2581,7 +2606,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cacheEntries": "{count} entries",
|
"cacheEntries": "{count} записей",
|
||||||
"@cacheEntries": {
|
"@cacheEntries": {
|
||||||
"description": "Track cache entry count",
|
"description": "Track cache entry count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2603,7 +2628,7 @@
|
|||||||
"@cacheClearConfirmTitle": {
|
"@cacheClearConfirmTitle": {
|
||||||
"description": "Dialog title before clearing one cache category"
|
"description": "Dialog title before clearing one cache category"
|
||||||
},
|
},
|
||||||
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
|
"cacheClearConfirmMessage": "Это очистит кэш для {target}. Загруженные музыкальные файлы не будут удалены.",
|
||||||
"@cacheClearConfirmMessage": {
|
"@cacheClearConfirmMessage": {
|
||||||
"description": "Dialog message before clearing selected cache",
|
"description": "Dialog message before clearing selected cache",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2632,7 +2657,7 @@
|
|||||||
"@cacheCleanupUnusedSubtitle": {
|
"@cacheCleanupUnusedSubtitle": {
|
||||||
"description": "Subtitle for cleanup unused data action"
|
"description": "Subtitle for cleanup unused data action"
|
||||||
},
|
},
|
||||||
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
|
"cacheCleanupResult": "Очистка завершена: {downloadCount} потерянных загрузок, {libraryCount} отсутствующих записей в библиотеке",
|
||||||
"@cacheCleanupResult": {
|
"@cacheCleanupResult": {
|
||||||
"description": "Snackbar after unused data cleanup",
|
"description": "Snackbar after unused data cleanup",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2664,15 +2689,15 @@
|
|||||||
"@trackSaveLyricsSubtitle": {
|
"@trackSaveLyricsSubtitle": {
|
||||||
"description": "Subtitle for save lyrics action"
|
"description": "Subtitle for save lyrics action"
|
||||||
},
|
},
|
||||||
"trackSaveLyricsProgress": "Saving lyrics...",
|
"trackSaveLyricsProgress": "Сохранение текста...",
|
||||||
"@trackSaveLyricsProgress": {
|
"@trackSaveLyricsProgress": {
|
||||||
"description": "Snackbar while saving lyrics to file"
|
"description": "Snackbar while saving lyrics to file"
|
||||||
},
|
},
|
||||||
"trackReEnrich": "Re-enrich",
|
"trackReEnrich": "Обновить",
|
||||||
"@trackReEnrich": {
|
"@trackReEnrich": {
|
||||||
"description": "Menu action - re-embed metadata into audio file"
|
"description": "Menu action - re-embed metadata into audio file"
|
||||||
},
|
},
|
||||||
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
|
"trackReEnrichOnlineSubtitle": "Поиск в сети метаданных и встраивание в файл",
|
||||||
"@trackReEnrichOnlineSubtitle": {
|
"@trackReEnrichOnlineSubtitle": {
|
||||||
"description": "Subtitle for re-enrich metadata action for local items"
|
"description": "Subtitle for re-enrich metadata action for local items"
|
||||||
},
|
},
|
||||||
@@ -2702,7 +2727,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackReEnrichProgress": "Re-enriching metadata...",
|
"trackReEnrichProgress": "Обновление метаданных...",
|
||||||
"@trackReEnrichProgress": {
|
"@trackReEnrichProgress": {
|
||||||
"description": "Snackbar while re-enriching metadata"
|
"description": "Snackbar while re-enriching metadata"
|
||||||
},
|
},
|
||||||
@@ -2710,7 +2735,7 @@
|
|||||||
"@trackReEnrichSearching": {
|
"@trackReEnrichSearching": {
|
||||||
"description": "Snackbar while searching metadata from internet for local items"
|
"description": "Snackbar while searching metadata from internet for local items"
|
||||||
},
|
},
|
||||||
"trackReEnrichSuccess": "Metadata re-enriched successfully",
|
"trackReEnrichSuccess": "Метаданные успешно обновлены",
|
||||||
"@trackReEnrichSuccess": {
|
"@trackReEnrichSuccess": {
|
||||||
"description": "Snackbar after successful re-enrichment"
|
"description": "Snackbar after successful re-enrichment"
|
||||||
},
|
},
|
||||||
@@ -2727,31 +2752,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertFormat": "Convert Format",
|
"trackConvertFormat": "Переконвертировать формат",
|
||||||
"@trackConvertFormat": {
|
"@trackConvertFormat": {
|
||||||
"description": "Menu item - convert audio format"
|
"description": "Menu item - convert audio format"
|
||||||
},
|
},
|
||||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
"trackConvertFormatSubtitle": "Конвертировать в MP3 или Opus",
|
||||||
"@trackConvertFormatSubtitle": {
|
"@trackConvertFormatSubtitle": {
|
||||||
"description": "Subtitle for convert format menu item"
|
"description": "Subtitle for convert format menu item"
|
||||||
},
|
},
|
||||||
"trackConvertTitle": "Convert Audio",
|
"trackConvertTitle": "Конвертировать аудио",
|
||||||
"@trackConvertTitle": {
|
"@trackConvertTitle": {
|
||||||
"description": "Title of convert bottom sheet"
|
"description": "Title of convert bottom sheet"
|
||||||
},
|
},
|
||||||
"trackConvertTargetFormat": "Target Format",
|
"trackConvertTargetFormat": "Целевой формат",
|
||||||
"@trackConvertTargetFormat": {
|
"@trackConvertTargetFormat": {
|
||||||
"description": "Label for format selection"
|
"description": "Label for format selection"
|
||||||
},
|
},
|
||||||
"trackConvertBitrate": "Bitrate",
|
"trackConvertBitrate": "Битрейт",
|
||||||
"@trackConvertBitrate": {
|
"@trackConvertBitrate": {
|
||||||
"description": "Label for bitrate selection"
|
"description": "Label for bitrate selection"
|
||||||
},
|
},
|
||||||
"trackConvertConfirmTitle": "Confirm Conversion",
|
"trackConvertConfirmTitle": "Подтвердить конвертацию",
|
||||||
"@trackConvertConfirmTitle": {
|
"@trackConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title"
|
"description": "Confirmation dialog title"
|
||||||
},
|
},
|
||||||
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
|
"trackConvertConfirmMessage": "Конвертировать из {sourceFormat} в {targetFormat} {bitrate}?\n\nОригинальный файл будет удален после конвертации.",
|
||||||
"@trackConvertConfirmMessage": {
|
"@trackConvertConfirmMessage": {
|
||||||
"description": "Confirmation dialog message",
|
"description": "Confirmation dialog message",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2766,11 +2791,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertConverting": "Converting audio...",
|
"trackConvertConverting": "Конвертация аудио...",
|
||||||
"@trackConvertConverting": {
|
"@trackConvertConverting": {
|
||||||
"description": "Snackbar while converting"
|
"description": "Snackbar while converting"
|
||||||
},
|
},
|
||||||
"trackConvertSuccess": "Converted to {format} successfully",
|
"trackConvertSuccess": "Успешно конвертировано в {format}",
|
||||||
"@trackConvertSuccess": {
|
"@trackConvertSuccess": {
|
||||||
"description": "Snackbar after successful conversion",
|
"description": "Snackbar after successful conversion",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2779,10 +2804,287 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertFailed": "Conversion failed",
|
"trackConvertFailed": "Ошибка конвертации",
|
||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"actionCreate": "Создать",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "Мои папки",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Список желаемого",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Любимые",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Плейлисты",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Плейлист",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Добавить в плейлист",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Создать плейлист",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "Плейлисты отсутствуют",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Создайте плейлист, чтобы начать классифицировать треки",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Добавлено в \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Уже в \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Плейлист создан",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Название плейлиста",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Имя плейлиста обязательно",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Переименовать плейлист",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Удалить плейлист",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Удалить \"{playlistName}\" и все треки внутри него?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Плейлист удалён",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Плейлист переименован",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Список желаний пуст",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Папка Любимые пуста",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Плейлист пуст",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Удерживайте + на любом треке, чтобы добавить его сюда",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Удалить из плейлиста",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Убрать из папки",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" удалён",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" добавлен в Любимые",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" удалено из Любимых",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" добавлен в список желаний",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" удалён из списка желаний",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Добавить в Любимое",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Исключить из Любимых",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Добавить в список желаний",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Удалить из списка желаний",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Изменить обложку",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Удалить обложку",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "Не выбраны конвертируемые треки",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Пакетная конвертация",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Конвертация {current} из {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Конвертировано {success} треков {total} в {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} скачано",
|
"downloadedAlbumDownloadedCount": "{count} скачано",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2792,7 +3094,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
|
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Для папок исполнителей используется исполнитель альбома, если он указан",
|
||||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
||||||
"description": "Subtitle when Album Artist is used for folder naming"
|
"description": "Subtitle when Album Artist is used for folder naming"
|
||||||
},
|
},
|
||||||
@@ -2800,4 +3102,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.",
|
"aboutAppDescription": "Spotify şarkılarını Tidal ve Qobuz'den yüksek kalitede indir.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1089,7 +1089,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Dahili",
|
"providerBuiltIn": "Dahili",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Eklenti",
|
"providerExtension": "Eklenti",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -2358,7 +2358,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Tidal, Qobuz veya Deezer'den FLAC kalitesinde ses alın",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -402,7 +402,7 @@
|
|||||||
"@aboutDabMusicDesc": {
|
"@aboutDabMusicDesc": {
|
||||||
"description": "Credit for DAB Music API"
|
"description": "Credit for DAB Music API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1005,7 +1005,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
|
|||||||
+303
-1
@@ -991,6 +991,14 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "No organization",
|
"folderOrganizationNone": "No organization",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
@@ -1749,6 +1757,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2214,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2783,6 +2808,283 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3102,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+303
-1
@@ -991,6 +991,14 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "No organization",
|
"folderOrganizationNone": "No organization",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
@@ -1749,6 +1757,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2214,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2783,6 +2808,283 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3102,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ class DownloadItem {
|
|||||||
final DownloadErrorType? errorType;
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? qualityOverride; // Override quality for this specific download
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
final String? playlistName; // Playlist context for folder organization
|
||||||
|
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -48,6 +49,7 @@ class DownloadItem {
|
|||||||
this.errorType,
|
this.errorType,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.qualityOverride,
|
this.qualityOverride,
|
||||||
|
this.playlistName,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadItem copyWith({
|
DownloadItem copyWith({
|
||||||
@@ -63,6 +65,7 @@ class DownloadItem {
|
|||||||
DownloadErrorType? errorType,
|
DownloadErrorType? errorType,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
|
String? playlistName,
|
||||||
}) {
|
}) {
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -77,6 +80,7 @@ class DownloadItem {
|
|||||||
errorType: errorType ?? this.errorType,
|
errorType: errorType ?? this.errorType,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
|
playlistName: playlistName ?? this.playlistName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
qualityOverride: json['qualityOverride'] as String?,
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
|
playlistName: json['playlistName'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||||
@@ -37,6 +38,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
'qualityOverride': instance.qualityOverride,
|
'qualityOverride': instance.qualityOverride,
|
||||||
|
'playlistName': instance.playlistName,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$DownloadStatusEnumMap = {
|
const _$DownloadStatusEnumMap = {
|
||||||
|
|||||||
@@ -55,17 +55,16 @@ class AppSettings {
|
|||||||
final String
|
final String
|
||||||
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
||||||
|
|
||||||
// Local Library Settings
|
|
||||||
final bool localLibraryEnabled; // Enable local library scanning
|
final bool localLibraryEnabled; // Enable local library scanning
|
||||||
final String localLibraryPath; // Path to scan for audio files
|
final String localLibraryPath; // Path to scan for audio files
|
||||||
|
final String
|
||||||
|
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||||
final bool
|
final bool
|
||||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||||
|
|
||||||
// Tutorial/Onboarding
|
|
||||||
final bool
|
final bool
|
||||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||||
|
|
||||||
// Lyrics Provider Settings
|
|
||||||
final List<String>
|
final List<String>
|
||||||
lyricsProviders; // Ordered list of enabled lyrics provider IDs
|
lyricsProviders; // Ordered list of enabled lyrics provider IDs
|
||||||
final bool
|
final bool
|
||||||
@@ -77,7 +76,6 @@ class AppSettings {
|
|||||||
final String
|
final String
|
||||||
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
|
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
|
||||||
|
|
||||||
// Version upgrade tracking
|
|
||||||
final String
|
final String
|
||||||
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
||||||
|
|
||||||
@@ -106,7 +104,7 @@ class AppSettings {
|
|||||||
this.askQualityBeforeDownload = true,
|
this.askQualityBeforeDownload = true,
|
||||||
this.spotifyClientId = '',
|
this.spotifyClientId = '',
|
||||||
this.spotifyClientSecret = '',
|
this.spotifyClientSecret = '',
|
||||||
this.useCustomSpotifyCredentials = true,
|
this.useCustomSpotifyCredentials = false,
|
||||||
this.metadataSource = 'deezer',
|
this.metadataSource = 'deezer',
|
||||||
this.enableLogging = false,
|
this.enableLogging = false,
|
||||||
this.useExtensionProviders = true,
|
this.useExtensionProviders = true,
|
||||||
@@ -124,13 +122,11 @@ class AppSettings {
|
|||||||
this.downloadNetworkMode = 'any',
|
this.downloadNetworkMode = 'any',
|
||||||
this.networkCompatibilityMode = false,
|
this.networkCompatibilityMode = false,
|
||||||
this.songLinkRegion = 'US',
|
this.songLinkRegion = 'US',
|
||||||
// Local Library defaults
|
|
||||||
this.localLibraryEnabled = false,
|
this.localLibraryEnabled = false,
|
||||||
this.localLibraryPath = '',
|
this.localLibraryPath = '',
|
||||||
|
this.localLibraryBookmark = '',
|
||||||
this.localLibraryShowDuplicates = true,
|
this.localLibraryShowDuplicates = true,
|
||||||
// Tutorial default
|
|
||||||
this.hasCompletedTutorial = false,
|
this.hasCompletedTutorial = false,
|
||||||
// Lyrics providers default order
|
|
||||||
this.lyricsProviders = const [
|
this.lyricsProviders = const [
|
||||||
'lrclib',
|
'lrclib',
|
||||||
'spotify_api',
|
'spotify_api',
|
||||||
@@ -143,7 +139,6 @@ class AppSettings {
|
|||||||
this.lyricsIncludeRomanizationNetease = false,
|
this.lyricsIncludeRomanizationNetease = false,
|
||||||
this.lyricsMultiPersonWordByWord = false,
|
this.lyricsMultiPersonWordByWord = false,
|
||||||
this.musixmatchLanguage = '',
|
this.musixmatchLanguage = '',
|
||||||
// Version upgrade tracking
|
|
||||||
this.lastSeenVersion = '',
|
this.lastSeenVersion = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +149,7 @@ class AppSettings {
|
|||||||
String? downloadDirectory,
|
String? downloadDirectory,
|
||||||
String? storageMode,
|
String? storageMode,
|
||||||
String? downloadTreeUri,
|
String? downloadTreeUri,
|
||||||
bool? autoFallback,
|
bool? autoFallback,
|
||||||
bool? embedMetadata,
|
bool? embedMetadata,
|
||||||
bool? embedLyrics,
|
bool? embedLyrics,
|
||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
@@ -191,19 +186,16 @@ class AppSettings {
|
|||||||
String? downloadNetworkMode,
|
String? downloadNetworkMode,
|
||||||
bool? networkCompatibilityMode,
|
bool? networkCompatibilityMode,
|
||||||
String? songLinkRegion,
|
String? songLinkRegion,
|
||||||
// Local Library
|
|
||||||
bool? localLibraryEnabled,
|
bool? localLibraryEnabled,
|
||||||
String? localLibraryPath,
|
String? localLibraryPath,
|
||||||
|
String? localLibraryBookmark,
|
||||||
bool? localLibraryShowDuplicates,
|
bool? localLibraryShowDuplicates,
|
||||||
// Tutorial
|
|
||||||
bool? hasCompletedTutorial,
|
bool? hasCompletedTutorial,
|
||||||
// Lyrics providers
|
|
||||||
List<String>? lyricsProviders,
|
List<String>? lyricsProviders,
|
||||||
bool? lyricsIncludeTranslationNetease,
|
bool? lyricsIncludeTranslationNetease,
|
||||||
bool? lyricsIncludeRomanizationNetease,
|
bool? lyricsIncludeRomanizationNetease,
|
||||||
bool? lyricsMultiPersonWordByWord,
|
bool? lyricsMultiPersonWordByWord,
|
||||||
String? musixmatchLanguage,
|
String? musixmatchLanguage,
|
||||||
// Version upgrade tracking
|
|
||||||
String? lastSeenVersion,
|
String? lastSeenVersion,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
@@ -259,14 +251,12 @@ class AppSettings {
|
|||||||
networkCompatibilityMode:
|
networkCompatibilityMode:
|
||||||
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
||||||
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
||||||
// Local Library
|
|
||||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||||
|
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||||
// Tutorial
|
|
||||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||||
// Lyrics providers
|
|
||||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||||
lyricsIncludeTranslationNetease:
|
lyricsIncludeTranslationNetease:
|
||||||
lyricsIncludeTranslationNetease ??
|
lyricsIncludeTranslationNetease ??
|
||||||
@@ -277,7 +267,6 @@ class AppSettings {
|
|||||||
lyricsMultiPersonWordByWord:
|
lyricsMultiPersonWordByWord:
|
||||||
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
||||||
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
||||||
// Version upgrade tracking
|
|
||||||
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
useCustomSpotifyCredentials:
|
useCustomSpotifyCredentials:
|
||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? false,
|
||||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||||
@@ -55,6 +55,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
||||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||||
|
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||||
@@ -128,6 +129,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'songLinkRegion': instance.songLinkRegion,
|
'songLinkRegion': instance.songLinkRegion,
|
||||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||||
'localLibraryPath': instance.localLibraryPath,
|
'localLibraryPath': instance.localLibraryPath,
|
||||||
|
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||||
'lyricsProviders': instance.lyricsProviders,
|
'lyricsProviders': instance.lyricsProviders,
|
||||||
|
|||||||
+13
-1
@@ -21,6 +21,7 @@ class Track {
|
|||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
final String? source;
|
final String? source;
|
||||||
final String? albumType;
|
final String? albumType;
|
||||||
|
final int? totalTracks;
|
||||||
final String? itemType;
|
final String? itemType;
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
@@ -41,10 +42,21 @@ class Track {
|
|||||||
this.availability,
|
this.availability,
|
||||||
this.source,
|
this.source,
|
||||||
this.albumType,
|
this.albumType,
|
||||||
|
this.totalTracks,
|
||||||
this.itemType,
|
this.itemType,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
bool get isSingle {
|
||||||
|
switch (albumType?.toLowerCase()) {
|
||||||
|
case 'single':
|
||||||
|
return true;
|
||||||
|
case 'ep':
|
||||||
|
final count = totalTracks;
|
||||||
|
return count == null || count <= 1;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool get isAlbumItem => itemType == 'album';
|
bool get isAlbumItem => itemType == 'album';
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
),
|
),
|
||||||
source: json['source'] as String?,
|
source: json['source'] as String?,
|
||||||
albumType: json['albumType'] as String?,
|
albumType: json['albumType'] as String?,
|
||||||
|
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||||
itemType: json['itemType'] as String?,
|
itemType: json['itemType'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
'source': instance.source,
|
'source': instance.source,
|
||||||
'albumType': instance.albumType,
|
'albumType': instance.albumType,
|
||||||
|
'totalTracks': instance.totalTracks,
|
||||||
'itemType': instance.itemType,
|
'itemType': instance.itemType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -251,9 +251,11 @@ class DownloadHistoryState {
|
|||||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||||
static const int _safRepairBatchSize = 20;
|
static const int _safRepairBatchSize = 20;
|
||||||
static const int _safRepairMaxPerLaunch = 60;
|
static const int _safRepairMaxPerLaunch = 60;
|
||||||
|
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
||||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
bool _isSafRepairInProgress = false;
|
bool _isSafRepairInProgress = false;
|
||||||
|
bool _isAudioMetadataBackfillInProgress = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadHistoryState build() {
|
DownloadHistoryState build() {
|
||||||
@@ -298,9 +300,19 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
maxItems: _safRepairMaxPerLaunch,
|
maxItems: _safRepairMaxPerLaunch,
|
||||||
);
|
);
|
||||||
await cleanupOrphanedDownloads();
|
await cleanupOrphanedDownloads();
|
||||||
|
await _backfillAudioMetadata(
|
||||||
|
state.items,
|
||||||
|
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Future.microtask(() => cleanupOrphanedDownloads());
|
Future.microtask(() async {
|
||||||
|
await cleanupOrphanedDownloads();
|
||||||
|
await _backfillAudioMetadata(
|
||||||
|
state.items,
|
||||||
|
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_historyLog.e('Failed to load history from database: $e', e, stack);
|
_historyLog.e('Failed to load history from database: $e', e, stack);
|
||||||
@@ -429,6 +441,157 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _readPositiveInt(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) {
|
||||||
|
final asInt = value.toInt();
|
||||||
|
return asInt > 0 ? asInt : null;
|
||||||
|
}
|
||||||
|
final parsed = int.tryParse(value.toString());
|
||||||
|
if (parsed == null || parsed <= 0) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _supportsAudioMetadataProbe(String filePath) {
|
||||||
|
final trimmed = filePath.trim().toLowerCase();
|
||||||
|
if (trimmed.isEmpty) return false;
|
||||||
|
if (trimmed.startsWith('content://')) return true;
|
||||||
|
return trimmed.endsWith('.flac') ||
|
||||||
|
trimmed.endsWith('.m4a') ||
|
||||||
|
trimmed.endsWith('.aac') ||
|
||||||
|
trimmed.endsWith('.mp3') ||
|
||||||
|
trimmed.endsWith('.opus') ||
|
||||||
|
trimmed.endsWith('.ogg');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) {
|
||||||
|
if (!_supportsAudioMetadataProbe(item.filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmedPath = item.filePath.trim().toLowerCase();
|
||||||
|
final hasResolvedSpecs =
|
||||||
|
item.bitDepth != null &&
|
||||||
|
item.bitDepth! > 0 &&
|
||||||
|
item.sampleRate != null &&
|
||||||
|
item.sampleRate! > 0;
|
||||||
|
final needsLosslessSpecProbe =
|
||||||
|
!hasResolvedSpecs &&
|
||||||
|
(trimmedPath.endsWith('.flac') ||
|
||||||
|
trimmedPath.endsWith('.m4a') ||
|
||||||
|
trimmedPath.endsWith('.aac') ||
|
||||||
|
trimmedPath.startsWith('content://'));
|
||||||
|
|
||||||
|
if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsLosslessSpecProbe ||
|
||||||
|
isPlaceholderQualityLabel(item.quality) ||
|
||||||
|
normalizeOptionalString(item.quality) == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> _probeAudioMetadata(
|
||||||
|
String filePath, {
|
||||||
|
String? fallbackQuality,
|
||||||
|
}) async {
|
||||||
|
if (!_supportsAudioMetadataProbe(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await PlatformBridge.readFileMetadata(filePath);
|
||||||
|
if (result['error'] != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bitDepth = _readPositiveInt(result['bit_depth']);
|
||||||
|
final sampleRate = _readPositiveInt(result['sample_rate']);
|
||||||
|
final quality = buildDisplayAudioQuality(
|
||||||
|
bitDepth: bitDepth,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
storedQuality: fallbackQuality,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (quality == null && bitDepth == null && sampleRate == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'quality': quality,
|
||||||
|
'bitDepth': bitDepth,
|
||||||
|
'sampleRate': sampleRate,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
_historyLog.d('Audio metadata probe failed for $filePath: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _backfillAudioMetadata(
|
||||||
|
List<DownloadHistoryItem> items, {
|
||||||
|
required int maxItems,
|
||||||
|
}) async {
|
||||||
|
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isAudioMetadataBackfillInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var refreshedCount = 0;
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
if (refreshedCount >= maxItems) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!_shouldBackfillAudioMetadata(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final probed = await _probeAudioMetadata(
|
||||||
|
item.filePath,
|
||||||
|
fallbackQuality: item.quality,
|
||||||
|
);
|
||||||
|
if (probed == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolvedQuality = normalizeOptionalString(
|
||||||
|
probed['quality'] as String?,
|
||||||
|
);
|
||||||
|
final resolvedBitDepth = probed['bitDepth'] as int?;
|
||||||
|
final resolvedSampleRate = probed['sampleRate'] as int?;
|
||||||
|
|
||||||
|
final qualityChanged =
|
||||||
|
resolvedQuality != null && resolvedQuality != item.quality;
|
||||||
|
final bitDepthChanged =
|
||||||
|
resolvedBitDepth != null && resolvedBitDepth != item.bitDepth;
|
||||||
|
final sampleRateChanged =
|
||||||
|
resolvedSampleRate != null && resolvedSampleRate != item.sampleRate;
|
||||||
|
|
||||||
|
if (!qualityChanged && !bitDepthChanged && !sampleRateChanged) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAudioMetadataForItem(
|
||||||
|
id: item.id,
|
||||||
|
quality: resolvedQuality,
|
||||||
|
bitDepth: resolvedBitDepth,
|
||||||
|
sampleRate: resolvedSampleRate,
|
||||||
|
);
|
||||||
|
refreshedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshedCount > 0) {
|
||||||
|
_historyLog.i(
|
||||||
|
'Audio metadata backfill refreshed $refreshedCount items',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isAudioMetadataBackfillInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> reloadFromStorage() async {
|
Future<void> reloadFromStorage() async {
|
||||||
await _loadFromDatabase();
|
await _loadFromDatabase();
|
||||||
}
|
}
|
||||||
@@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
return DownloadHistoryItem.fromJson(json);
|
return DownloadHistoryItem.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateAudioMetadataForItem({
|
||||||
|
required String id,
|
||||||
|
String? quality,
|
||||||
|
int? bitDepth,
|
||||||
|
int? sampleRate,
|
||||||
|
}) async {
|
||||||
|
final index = state.items.indexWhere((item) => item.id == id);
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
final current = state.items[index];
|
||||||
|
final updated = current.copyWith(
|
||||||
|
quality: quality,
|
||||||
|
bitDepth: bitDepth,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updated.quality == current.quality &&
|
||||||
|
updated.bitDepth == current.bitDepth &&
|
||||||
|
updated.sampleRate == current.sampleRate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedItems = [...state.items];
|
||||||
|
updatedItems[index] = updated;
|
||||||
|
state = state.copyWith(items: updatedItems);
|
||||||
|
await _db.updateAudioMetadata(
|
||||||
|
id,
|
||||||
|
newQuality: quality,
|
||||||
|
newBitDepth: bitDepth,
|
||||||
|
newSampleRate: sampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateMetadataForItem({
|
Future<void> updateMetadataForItem({
|
||||||
required String id,
|
required String id,
|
||||||
required String trackName,
|
required String trackName,
|
||||||
@@ -592,10 +788,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
final deletedCount = await _db.deleteByIds(orphanedIds);
|
final deletedCount = await _db.deleteByIds(orphanedIds);
|
||||||
|
|
||||||
// Update in-memory state
|
|
||||||
final orphanedSet = orphanedIds.toSet();
|
final orphanedSet = orphanedIds.toSet();
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: state.items
|
items: state.items
|
||||||
@@ -1379,6 +1573,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
bool useAlbumArtistForFolders = true,
|
bool useAlbumArtistForFolders = true,
|
||||||
bool usePrimaryArtistOnly = false,
|
bool usePrimaryArtistOnly = false,
|
||||||
bool filterContributingArtistsInAlbumArtist = false,
|
bool filterContributingArtistsInAlbumArtist = false,
|
||||||
|
String? playlistName,
|
||||||
}) async {
|
}) async {
|
||||||
String baseDir = state.outputDir;
|
String baseDir = state.outputDir;
|
||||||
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
|
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
|
||||||
@@ -1453,6 +1648,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
String subPath = '';
|
String subPath = '';
|
||||||
switch (folderOrganization) {
|
switch (folderOrganization) {
|
||||||
|
case 'playlist':
|
||||||
|
if (playlistName != null && playlistName.isNotEmpty) {
|
||||||
|
subPath = _sanitizeFolderName(playlistName);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'artist':
|
case 'artist':
|
||||||
final artistName = _sanitizeFolderName(folderArtist);
|
final artistName = _sanitizeFolderName(folderArtist);
|
||||||
subPath = artistName;
|
subPath = artistName;
|
||||||
@@ -1531,6 +1731,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
bool useAlbumArtistForFolders = true,
|
bool useAlbumArtistForFolders = true,
|
||||||
bool usePrimaryArtistOnly = false,
|
bool usePrimaryArtistOnly = false,
|
||||||
bool filterContributingArtistsInAlbumArtist = false,
|
bool filterContributingArtistsInAlbumArtist = false,
|
||||||
|
String? playlistName,
|
||||||
}) async {
|
}) async {
|
||||||
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
|
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
|
||||||
var folderArtist = useAlbumArtistForFolders
|
var folderArtist = useAlbumArtistForFolders
|
||||||
@@ -1582,6 +1783,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (folderOrganization) {
|
switch (folderOrganization) {
|
||||||
|
case 'playlist':
|
||||||
|
if (playlistName != null && playlistName.isNotEmpty) {
|
||||||
|
return _sanitizeFolderName(playlistName);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
case 'artist':
|
case 'artist':
|
||||||
return _sanitizeFolderName(folderArtist);
|
return _sanitizeFolderName(folderArtist);
|
||||||
case 'album':
|
case 'album':
|
||||||
@@ -1596,18 +1802,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _determineOutputExt(String quality, String service) {
|
String _determineOutputExt(String quality, String service) {
|
||||||
// YouTube provider - lossy only (Opus or MP3)
|
|
||||||
if (service.toLowerCase() == 'youtube') {
|
if (service.toLowerCase() == 'youtube') {
|
||||||
if (quality.toLowerCase().contains('mp3')) {
|
if (quality.toLowerCase().contains('mp3')) {
|
||||||
return '.mp3';
|
return '.mp3';
|
||||||
}
|
}
|
||||||
return '.opus';
|
return '.opus';
|
||||||
}
|
}
|
||||||
// Amazon stream is delivered as MP4/M4A container (may contain FLAC audio),
|
|
||||||
// so SAF should keep .m4a before decrypt/convert pipeline.
|
|
||||||
if (service.toLowerCase() == 'amazon') {
|
|
||||||
return '.m4a';
|
|
||||||
}
|
|
||||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||||
return '.m4a';
|
return '.m4a';
|
||||||
}
|
}
|
||||||
@@ -1712,7 +1912,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String addToQueue(Track track, String service, {String? qualityOverride}) {
|
String addToQueue(Track track, String service, {String? qualityOverride, String? playlistName}) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
@@ -1724,6 +1924,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
service: service,
|
service: service,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
qualityOverride: qualityOverride,
|
qualityOverride: qualityOverride,
|
||||||
|
playlistName: playlistName,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(items: [...state.items, item]);
|
state = state.copyWith(items: [...state.items, item]);
|
||||||
@@ -1740,6 +1941,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
List<Track> tracks,
|
List<Track> tracks,
|
||||||
String service, {
|
String service, {
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
|
String? playlistName,
|
||||||
}) {
|
}) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
@@ -1754,6 +1956,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
service: service,
|
service: service,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
qualityOverride: qualityOverride,
|
qualityOverride: qualityOverride,
|
||||||
|
playlistName: playlistName,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -2159,6 +2362,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
deezerId: baseTrack.deezerId,
|
deezerId: baseTrack.deezerId,
|
||||||
availability: baseTrack.availability,
|
availability: baseTrack.availability,
|
||||||
albumType: baseTrack.albumType,
|
albumType: baseTrack.albumType,
|
||||||
|
totalTracks: baseTrack.totalTracks,
|
||||||
source: baseTrack.source,
|
source: baseTrack.source,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2903,7 +3107,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
failedCount: _failedInSession,
|
failedCount: _failedInSession,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-export failed downloads if enabled
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
|
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
|
||||||
final exportPath = await exportFailedDownloads();
|
final exportPath = await exportFailedDownloads();
|
||||||
@@ -3072,6 +3275,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
albumType:
|
albumType:
|
||||||
(data['album_type'] as String?) ??
|
(data['album_type'] as String?) ??
|
||||||
trackToDownload.albumType,
|
trackToDownload.albumType,
|
||||||
|
totalTracks:
|
||||||
|
data['total_tracks'] as int? ?? trackToDownload.totalTracks,
|
||||||
source: trackToDownload.source,
|
source: trackToDownload.source,
|
||||||
);
|
);
|
||||||
_log.d(
|
_log.d(
|
||||||
@@ -3130,6 +3335,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||||
filterContributingArtistsInAlbumArtist:
|
filterContributingArtistsInAlbumArtist:
|
||||||
settings.filterContributingArtistsInAlbumArtist,
|
settings.filterContributingArtistsInAlbumArtist,
|
||||||
|
playlistName: item.playlistName,
|
||||||
)
|
)
|
||||||
: '';
|
: '';
|
||||||
String? appOutputDir;
|
String? appOutputDir;
|
||||||
@@ -3144,6 +3350,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||||
filterContributingArtistsInAlbumArtist:
|
filterContributingArtistsInAlbumArtist:
|
||||||
settings.filterContributingArtistsInAlbumArtist,
|
settings.filterContributingArtistsInAlbumArtist,
|
||||||
|
playlistName: item.playlistName,
|
||||||
);
|
);
|
||||||
var effectiveOutputDir = initialOutputDir;
|
var effectiveOutputDir = initialOutputDir;
|
||||||
var effectiveSafMode = isSafMode;
|
var effectiveSafMode = isSafMode;
|
||||||
@@ -3206,7 +3413,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
!trackToDownload.id.startsWith('deezer:') &&
|
!trackToDownload.id.startsWith('deezer:') &&
|
||||||
!trackToDownload.id.startsWith('extension:')) {
|
!trackToDownload.id.startsWith('extension:')) {
|
||||||
try {
|
try {
|
||||||
// Extract clean Spotify ID (remove spotify: prefix if present)
|
|
||||||
String spotifyId = trackToDownload.id;
|
String spotifyId = trackToDownload.id;
|
||||||
if (spotifyId.startsWith('spotify:track:')) {
|
if (spotifyId.startsWith('spotify:track:')) {
|
||||||
spotifyId = spotifyId.split(':').last;
|
spotifyId = spotifyId.split(':').last;
|
||||||
@@ -3285,6 +3491,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
deezerId: deezerTrackId,
|
deezerId: deezerTrackId,
|
||||||
availability: trackToDownload.availability,
|
availability: trackToDownload.availability,
|
||||||
albumType: trackToDownload.albumType,
|
albumType: trackToDownload.albumType,
|
||||||
|
totalTracks: trackToDownload.totalTracks,
|
||||||
source: trackToDownload.source,
|
source: trackToDownload.source,
|
||||||
);
|
);
|
||||||
_log.d(
|
_log.d(
|
||||||
@@ -3506,11 +3713,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final decryptionKey =
|
final decryptionKey =
|
||||||
(result['decryption_key'] as String?)?.trim() ?? '';
|
(result['decryption_key'] as String?)?.trim() ?? '';
|
||||||
|
|
||||||
if (!wasExisting &&
|
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
|
||||||
decryptionKey.isNotEmpty &&
|
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
|
||||||
filePath != null &&
|
|
||||||
actualService == 'amazon') {
|
|
||||||
_log.i('Amazon encrypted stream detected, decrypting via FFmpeg...');
|
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
||||||
|
|
||||||
if (effectiveSafMode && isContentUri(filePath)) {
|
if (effectiveSafMode && isContentUri(filePath)) {
|
||||||
@@ -3539,7 +3743,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: 'Failed to decrypt Amazon stream',
|
error: 'Failed to decrypt encrypted stream',
|
||||||
errorType: DownloadErrorType.unknown,
|
errorType: DownloadErrorType.unknown,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -3564,7 +3768,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (newUri == null) {
|
if (newUri == null) {
|
||||||
_log.e('Failed to write decrypted Amazon stream back to SAF');
|
_log.e('Failed to write decrypted stream back to SAF');
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
@@ -3579,7 +3783,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
filePath = newUri;
|
filePath = newUri;
|
||||||
finalSafFileName = newFileName;
|
finalSafFileName = newFileName;
|
||||||
_log.i('Amazon SAF decryption completed');
|
_log.i('SAF decryption completed');
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await File(tempPath).delete();
|
await File(tempPath).delete();
|
||||||
@@ -3601,7 +3805,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: 'Failed to decrypt Amazon stream',
|
error: 'Failed to decrypt encrypted stream',
|
||||||
errorType: DownloadErrorType.unknown,
|
errorType: DownloadErrorType.unknown,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -3610,7 +3814,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filePath = decryptedPath;
|
filePath = decryptedPath;
|
||||||
_log.i('Amazon local decryption completed');
|
_log.i('Local decryption completed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3832,7 +4036,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Local file path flow (original)
|
|
||||||
if (quality == 'HIGH') {
|
if (quality == 'HIGH') {
|
||||||
final tidalHighFormat = settings.tidalHighFormat;
|
final tidalHighFormat = settings.tidalHighFormat;
|
||||||
_log.i(
|
_log.i(
|
||||||
@@ -4049,10 +4252,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
!effectiveSafMode &&
|
!effectiveSafMode &&
|
||||||
isFlacFile &&
|
isFlacFile &&
|
||||||
!wasExisting &&
|
!wasExisting &&
|
||||||
actualService == 'amazon' &&
|
|
||||||
decryptionKey.isNotEmpty) {
|
decryptionKey.isNotEmpty) {
|
||||||
_log.d(
|
_log.d(
|
||||||
'Local FLAC after Amazon decrypt detected, embedding metadata and cover...',
|
'Local FLAC after decrypt detected, embedding metadata and cover...',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
@@ -4112,7 +4314,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
final isContentUriPath = isContentUri(filePath);
|
final isContentUriPath = isContentUri(filePath);
|
||||||
if (isContentUriPath && effectiveSafMode) {
|
if (isContentUriPath && effectiveSafMode) {
|
||||||
// SAF mode: copy to temp, embed, write back
|
|
||||||
final tempPath = await _copySafToTemp(filePath);
|
final tempPath = await _copySafToTemp(filePath);
|
||||||
if (tempPath != null) {
|
if (tempPath != null) {
|
||||||
try {
|
try {
|
||||||
@@ -4133,7 +4334,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
copyright: backendCopyright,
|
copyright: backendCopyright,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Write back to SAF
|
|
||||||
final ext = isMp3File ? '.mp3' : '.opus';
|
final ext = isMp3File ? '.mp3' : '.opus';
|
||||||
final newFileName = '${safBaseName ?? 'track'}$ext';
|
final newFileName = '${safBaseName ?? 'track'}$ext';
|
||||||
final newUri = await _writeTempToSaf(
|
final newUri = await _writeTempToSaf(
|
||||||
@@ -4162,7 +4362,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-SAF mode: embed directly
|
|
||||||
try {
|
try {
|
||||||
if (isMp3File) {
|
if (isMp3File) {
|
||||||
await _embedMetadataToMp3(
|
await _embedMetadataToMp3(
|
||||||
@@ -4347,6 +4546,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
normalizeOptionalString(copyright) ??
|
normalizeOptionalString(copyright) ??
|
||||||
normalizeOptionalString(existingInHistory?.copyright);
|
normalizeOptionalString(existingInHistory?.copyright);
|
||||||
|
|
||||||
|
int? finalBitDepth = backendBitDepth;
|
||||||
|
int? finalSampleRate = backendSampleRate;
|
||||||
|
final lowerFilePath = filePath.toLowerCase();
|
||||||
|
final canProbeFinalMetadata =
|
||||||
|
filePath.startsWith('content://') ||
|
||||||
|
lowerFilePath.endsWith('.flac') ||
|
||||||
|
lowerFilePath.endsWith('.m4a') ||
|
||||||
|
lowerFilePath.endsWith('.aac') ||
|
||||||
|
lowerFilePath.endsWith('.mp3') ||
|
||||||
|
lowerFilePath.endsWith('.opus') ||
|
||||||
|
lowerFilePath.endsWith('.ogg');
|
||||||
|
|
||||||
|
if (canProbeFinalMetadata) {
|
||||||
|
try {
|
||||||
|
final metadata = await PlatformBridge.readFileMetadata(filePath);
|
||||||
|
if (metadata['error'] == null) {
|
||||||
|
final probedBitDepth = metadata['bit_depth'] is num
|
||||||
|
? (metadata['bit_depth'] as num).toInt()
|
||||||
|
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
|
||||||
|
final probedSampleRate = metadata['sample_rate'] is num
|
||||||
|
? (metadata['sample_rate'] as num).toInt()
|
||||||
|
: int.tryParse(metadata['sample_rate']?.toString() ?? '');
|
||||||
|
|
||||||
|
if (probedBitDepth != null && probedBitDepth > 0) {
|
||||||
|
finalBitDepth = probedBitDepth;
|
||||||
|
}
|
||||||
|
if (probedSampleRate != null && probedSampleRate > 0) {
|
||||||
|
finalSampleRate = probedSampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolvedQuality = buildDisplayAudioQuality(
|
||||||
|
bitDepth: finalBitDepth,
|
||||||
|
sampleRate: finalSampleRate,
|
||||||
|
storedQuality: actualQuality,
|
||||||
|
);
|
||||||
|
if (resolvedQuality != null) {
|
||||||
|
actualQuality = resolvedQuality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.d('Final audio metadata probe failed for $filePath: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||||
|
|
||||||
final historyAlbumArtist =
|
final historyAlbumArtist =
|
||||||
@@ -4354,9 +4597,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? resolvedAlbumArtist
|
? resolvedAlbumArtist
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final isMp3 = filePath.endsWith('.mp3');
|
final isLossyOutput =
|
||||||
final historyBitDepth = isMp3 ? null : backendBitDepth;
|
lowerFilePath.endsWith('.mp3') ||
|
||||||
final historySampleRate = isMp3 ? null : backendSampleRate;
|
lowerFilePath.endsWith('.opus') ||
|
||||||
|
lowerFilePath.endsWith('.ogg');
|
||||||
|
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
|
||||||
|
final historySampleRate = isLossyOutput ? null : finalSampleRate;
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(downloadHistoryProvider.notifier)
|
.read(downloadHistoryProvider.notifier)
|
||||||
|
|||||||
@@ -757,7 +757,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
|
|
||||||
Future<void> loadProviderPriority() async {
|
Future<void> loadProviderPriority() async {
|
||||||
try {
|
try {
|
||||||
// Load from SharedPreferences first (persisted)
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final savedJson = prefs.getString(_providerPriorityKey);
|
final savedJson = prefs.getString(_providerPriorityKey);
|
||||||
|
|
||||||
@@ -768,10 +767,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
priority = _sanitizeDownloadProviderPriority(priority);
|
priority = _sanitizeDownloadProviderPriority(priority);
|
||||||
_log.d('Loaded provider priority from prefs: $priority');
|
_log.d('Loaded provider priority from prefs: $priority');
|
||||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||||
// Sync to Go backend
|
|
||||||
await PlatformBridge.setProviderPriority(priority);
|
await PlatformBridge.setProviderPriority(priority);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to Go backend default
|
|
||||||
priority = await PlatformBridge.getProviderPriority();
|
priority = await PlatformBridge.getProviderPriority();
|
||||||
priority = _sanitizeDownloadProviderPriority(priority);
|
priority = _sanitizeDownloadProviderPriority(priority);
|
||||||
await PlatformBridge.setProviderPriority(priority);
|
await PlatformBridge.setProviderPriority(priority);
|
||||||
@@ -787,11 +784,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
Future<void> setProviderPriority(List<String> priority) async {
|
Future<void> setProviderPriority(List<String> priority) async {
|
||||||
try {
|
try {
|
||||||
final sanitized = _sanitizeDownloadProviderPriority(priority);
|
final sanitized = _sanitizeDownloadProviderPriority(priority);
|
||||||
// Save to SharedPreferences for persistence
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
|
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
|
||||||
|
|
||||||
// Sync to Go backend
|
|
||||||
await PlatformBridge.setProviderPriority(sanitized);
|
await PlatformBridge.setProviderPriority(sanitized);
|
||||||
state = state.copyWith(providerPriority: sanitized);
|
state = state.copyWith(providerPriority: sanitized);
|
||||||
_log.d('Saved provider priority: $sanitized');
|
_log.d('Saved provider priority: $sanitized');
|
||||||
@@ -811,7 +806,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) {
|
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
|
||||||
if (!result.contains(provider)) {
|
if (!result.contains(provider)) {
|
||||||
result.add(provider);
|
result.add(provider);
|
||||||
}
|
}
|
||||||
@@ -822,20 +817,25 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
|
|
||||||
Future<void> loadMetadataProviderPriority() async {
|
Future<void> loadMetadataProviderPriority() async {
|
||||||
try {
|
try {
|
||||||
// Load from SharedPreferences first (persisted)
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
||||||
|
|
||||||
List<String> priority;
|
List<String> priority;
|
||||||
if (savedJson != null) {
|
if (savedJson != null) {
|
||||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||||
priority = saved.map((e) => e as String).toList();
|
priority = _sanitizeMetadataProviderPriority(
|
||||||
|
saved.map((e) => e as String).toList(),
|
||||||
|
);
|
||||||
_log.d('Loaded metadata provider priority from prefs: $priority');
|
_log.d('Loaded metadata provider priority from prefs: $priority');
|
||||||
// Sync to Go backend
|
await prefs.setString(
|
||||||
|
_metadataProviderPriorityKey,
|
||||||
|
jsonEncode(priority),
|
||||||
|
);
|
||||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to Go backend default
|
priority = _sanitizeMetadataProviderPriority(
|
||||||
priority = await PlatformBridge.getMetadataProviderPriority();
|
await PlatformBridge.getMetadataProviderPriority(),
|
||||||
|
);
|
||||||
_log.d('Using default metadata provider priority: $priority');
|
_log.d('Using default metadata provider priority: $priority');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,14 +847,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
|
|
||||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||||
try {
|
try {
|
||||||
// Save to SharedPreferences for persistence
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
|
final sanitized = _sanitizeMetadataProviderPriority(priority);
|
||||||
|
await prefs.setString(
|
||||||
|
_metadataProviderPriorityKey,
|
||||||
|
jsonEncode(sanitized),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync to Go backend
|
await PlatformBridge.setMetadataProviderPriority(sanitized);
|
||||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
state = state.copyWith(metadataProviderPriority: sanitized);
|
||||||
state = state.copyWith(metadataProviderPriority: priority);
|
_log.d('Saved metadata provider priority: $sanitized');
|
||||||
_log.d('Saved metadata provider priority: $priority');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to set metadata provider priority: $e');
|
_log.e('Failed to set metadata provider priority: $e');
|
||||||
state = state.copyWith(error: e.toString());
|
state = state.copyWith(error: e.toString());
|
||||||
@@ -880,7 +882,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> getAllDownloadProviders() {
|
List<String> getAllDownloadProviders() {
|
||||||
final providers = ['tidal', 'qobuz', 'amazon', 'deezer'];
|
final providers = ['tidal', 'qobuz', 'deezer'];
|
||||||
for (final ext in state.extensions) {
|
for (final ext in state.extensions) {
|
||||||
if (ext.enabled && ext.hasDownloadProvider) {
|
if (ext.enabled && ext.hasDownloadProvider) {
|
||||||
providers.add(ext.id);
|
providers.add(ext.id);
|
||||||
@@ -890,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> getAllMetadataProviders() {
|
List<String> getAllMetadataProviders() {
|
||||||
final providers = ['deezer', 'spotify'];
|
final providers = ['deezer'];
|
||||||
for (final ext in state.extensions) {
|
for (final ext in state.extensions) {
|
||||||
if (ext.enabled && ext.hasMetadataProvider) {
|
if (ext.enabled && ext.hasMetadataProvider) {
|
||||||
providers.add(ext.id);
|
providers.add(ext.id);
|
||||||
@@ -899,6 +901,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
|
||||||
|
final allowed = getAllMetadataProviders().toSet();
|
||||||
|
final result = <String>[];
|
||||||
|
|
||||||
|
for (final provider in input) {
|
||||||
|
if (allowed.contains(provider) && !result.contains(provider)) {
|
||||||
|
result.add(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.contains('deezer')) {
|
||||||
|
result.insert(0, 'deezer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
List<Extension> get searchProviders {
|
List<Extension> get searchProviders {
|
||||||
return state.extensions
|
return state.extensions
|
||||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/services/library_database.dart';
|
|||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||||
|
|
||||||
final _log = AppLogger('LocalLibrary');
|
final _log = AppLogger('LocalLibrary');
|
||||||
|
|
||||||
@@ -193,74 +194,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
await _loadFromDatabase();
|
await _loadFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<String> _buildPathMatchKeys(String? filePath) {
|
|
||||||
final raw = filePath?.trim() ?? '';
|
|
||||||
if (raw.isEmpty) return const {};
|
|
||||||
|
|
||||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
|
|
||||||
final keys = <String>{};
|
|
||||||
|
|
||||||
void addNormalized(String value) {
|
|
||||||
final trimmed = value.trim();
|
|
||||||
if (trimmed.isEmpty) return;
|
|
||||||
keys.add(trimmed);
|
|
||||||
keys.add(trimmed.toLowerCase());
|
|
||||||
if (trimmed.contains('\\')) {
|
|
||||||
final slash = trimmed.replaceAll('\\', '/');
|
|
||||||
keys.add(slash);
|
|
||||||
keys.add(slash.toLowerCase());
|
|
||||||
}
|
|
||||||
if (trimmed.contains('%')) {
|
|
||||||
try {
|
|
||||||
final decoded = Uri.decodeFull(trimmed);
|
|
||||||
keys.add(decoded);
|
|
||||||
keys.add(decoded.toLowerCase());
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Uri? parsed;
|
|
||||||
try {
|
|
||||||
parsed = Uri.parse(trimmed);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
if (parsed != null && parsed.hasScheme) {
|
|
||||||
final noQueryOrFragment = parsed.replace(query: null, fragment: null);
|
|
||||||
keys.add(noQueryOrFragment.toString());
|
|
||||||
keys.add(noQueryOrFragment.toString().toLowerCase());
|
|
||||||
|
|
||||||
if (parsed.scheme == 'file') {
|
|
||||||
try {
|
|
||||||
final fileOnly = parsed.toFilePath();
|
|
||||||
if (fileOnly.isNotEmpty) {
|
|
||||||
keys.add(fileOnly);
|
|
||||||
keys.add(fileOnly.toLowerCase());
|
|
||||||
if (fileOnly.contains('\\')) {
|
|
||||||
final slash = fileOnly.replaceAll('\\', '/');
|
|
||||||
keys.add(slash);
|
|
||||||
keys.add(slash.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
} else if (trimmed.startsWith('/')) {
|
|
||||||
try {
|
|
||||||
final asFileUri = Uri.file(trimmed).toString();
|
|
||||||
keys.add(asFileUri);
|
|
||||||
keys.add(asFileUri.toLowerCase());
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addNormalized(cleaned);
|
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
|
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
|
||||||
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
|
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final candidateKeys = _buildPathMatchKeys(filePath);
|
final candidateKeys = buildPathMatchKeys(filePath);
|
||||||
for (final key in candidateKeys) {
|
for (final key in candidateKeys) {
|
||||||
if (downloadedPathKeys.contains(key)) {
|
if (downloadedPathKeys.contains(key)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -272,6 +210,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
Future<void> startScan(
|
Future<void> startScan(
|
||||||
String folderPath, {
|
String folderPath, {
|
||||||
bool forceFullScan = false,
|
bool forceFullScan = false,
|
||||||
|
String? iosBookmark,
|
||||||
}) async {
|
}) async {
|
||||||
if (state.isScanning) {
|
if (state.isScanning) {
|
||||||
_log.w('Scan already in progress');
|
_log.w('Scan already in progress');
|
||||||
@@ -316,8 +255,28 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
_startProgressPolling();
|
_startProgressPolling();
|
||||||
|
|
||||||
|
// On iOS, start accessing the security-scoped bookmark so the Go backend
|
||||||
|
// can read files outside the app sandbox.
|
||||||
|
String? resolvedPath;
|
||||||
|
bool didStartSecurityAccess = false;
|
||||||
|
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||||
|
resolvedPath = await PlatformBridge.startAccessingIosBookmark(
|
||||||
|
iosBookmark,
|
||||||
|
);
|
||||||
|
if (resolvedPath != null) {
|
||||||
|
didStartSecurityAccess = true;
|
||||||
|
_log.i('Started iOS security-scoped access: $resolvedPath');
|
||||||
|
} else {
|
||||||
|
_log.w(
|
||||||
|
'Failed to start iOS security-scoped access, '
|
||||||
|
'falling back to original path',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final effectiveFolderPath = resolvedPath ?? folderPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final isSaf = folderPath.startsWith('content://');
|
final isSaf = effectiveFolderPath.startsWith('content://');
|
||||||
|
|
||||||
// Get all file paths from download history to exclude them.
|
// Get all file paths from download history to exclude them.
|
||||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
// Merge DB + in-memory state to avoid race when a fresh download has not
|
||||||
@@ -334,7 +293,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
};
|
};
|
||||||
final downloadedPathKeys = <String>{};
|
final downloadedPathKeys = <String>{};
|
||||||
for (final path in allHistoryPaths) {
|
for (final path in allHistoryPaths) {
|
||||||
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
|
downloadedPathKeys.addAll(buildPathMatchKeys(path));
|
||||||
}
|
}
|
||||||
_log.i(
|
_log.i(
|
||||||
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
|
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
|
||||||
@@ -344,8 +303,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
if (forceFullScan) {
|
if (forceFullScan) {
|
||||||
// Full scan path - ignores existing data
|
// Full scan path - ignores existing data
|
||||||
final results = isSaf
|
final results = isSaf
|
||||||
? await PlatformBridge.scanSafTree(folderPath)
|
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||||
: await PlatformBridge.scanLibraryFolder(folderPath);
|
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
await _showScanCancelledNotification();
|
await _showScanCancelledNotification();
|
||||||
@@ -424,12 +383,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
final Map<String, dynamic> result;
|
final Map<String, dynamic> result;
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
result = await PlatformBridge.scanSafTreeIncremental(
|
result = await PlatformBridge.scanSafTreeIncremental(
|
||||||
folderPath,
|
effectiveFolderPath,
|
||||||
existingFiles,
|
existingFiles,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.scanLibraryFolderIncremental(
|
result = await PlatformBridge.scanLibraryFolderIncremental(
|
||||||
folderPath,
|
effectiveFolderPath,
|
||||||
existingFiles,
|
existingFiles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -553,6 +512,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||||
await _showScanFailedNotification(e.toString());
|
await _showScanFailedNotification(e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
|
if (didStartSecurityAccess) {
|
||||||
|
await PlatformBridge.stopAccessingIosBookmark();
|
||||||
|
_log.i('Stopped iOS security-scoped access');
|
||||||
|
}
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,12 +770,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> cleanupMissingFiles() async {
|
Future<int> cleanupMissingFiles({String? iosBookmark}) async {
|
||||||
final removed = await _db.cleanupMissingFiles();
|
bool didStartSecurityAccess = false;
|
||||||
if (removed > 0) {
|
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||||
await reloadFromStorage();
|
final resolved = await PlatformBridge.startAccessingIosBookmark(
|
||||||
|
iosBookmark,
|
||||||
|
);
|
||||||
|
if (resolved != null) {
|
||||||
|
didStartSecurityAccess = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final removed = await _db.cleanupMissingFiles();
|
||||||
|
if (removed > 0) {
|
||||||
|
await reloadFromStorage();
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
} finally {
|
||||||
|
if (didStartSecurityAccess) {
|
||||||
|
await PlatformBridge.stopAccessingIosBookmark();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearLibrary() async {
|
Future<void> clearLibrary() async {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class PlaybackController extends Notifier<PlaybackState> {
|
|||||||
String coverUrl = '',
|
String coverUrl = '',
|
||||||
Track? track,
|
Track? track,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (isCueVirtualPath(path)) {
|
||||||
|
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||||
|
}
|
||||||
_log.d('Opening external player for "$title" by $artist: $path');
|
_log.d('Opening external player for "$title" by $artist: $path');
|
||||||
await openFile(path);
|
await openFile(path);
|
||||||
}
|
}
|
||||||
@@ -32,11 +35,16 @@ class PlaybackController extends Notifier<PlaybackState> {
|
|||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
||||||
|
var skippedCueVirtualTrack = false;
|
||||||
for (final track in orderedTracks) {
|
for (final track in orderedTracks) {
|
||||||
final resolvedPath = await _resolveTrackPath(track);
|
final resolvedPath = await _resolveTrackPath(track);
|
||||||
if (resolvedPath == null) {
|
if (resolvedPath == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isCueVirtualPath(resolvedPath)) {
|
||||||
|
skippedCueVirtualTrack = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
_log.d(
|
_log.d(
|
||||||
'Opening first available external track for list playback: '
|
'Opening first available external track for list playback: '
|
||||||
@@ -46,6 +54,10 @@ class PlaybackController extends Notifier<PlaybackState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skippedCueVirtualTrack) {
|
||||||
|
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||||
|
}
|
||||||
|
|
||||||
throw Exception(
|
throw Exception(
|
||||||
'No local audio file is available to open. Download the track first.',
|
'No local audio file is available to open. Download the track first.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
const _currentMigrationVersion = 4;
|
const _currentMigrationVersion = 5;
|
||||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||||
final _log = AppLogger('SettingsProvider');
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
@@ -41,9 +41,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await _normalizeSongLinkRegionIfNeeded();
|
await _normalizeSongLinkRegionIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _loadSpotifyClientSecret(prefs);
|
await _retireBuiltInSpotifyProvider();
|
||||||
|
|
||||||
_applySpotifyCredentials();
|
|
||||||
|
|
||||||
LogBuffer.loggingEnabled = state.enableLogging;
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
|
|
||||||
@@ -105,6 +103,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
state = state.copyWith(lyricsProviders: updatedProviders);
|
state = state.copyWith(lyricsProviders: updatedProviders);
|
||||||
}
|
}
|
||||||
|
if (state.metadataSource != 'deezer' ||
|
||||||
|
state.spotifyClientId.isNotEmpty ||
|
||||||
|
state.spotifyClientSecret.isNotEmpty ||
|
||||||
|
state.useCustomSpotifyCredentials) {
|
||||||
|
state = state.copyWith(
|
||||||
|
metadataSource: 'deezer',
|
||||||
|
spotifyClientId: '',
|
||||||
|
spotifyClientSecret: '',
|
||||||
|
useCustomSpotifyCredentials: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
@@ -193,49 +202,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
Future<void> _retireBuiltInSpotifyProvider() async {
|
||||||
final storedSecret = await _secureStorage.read(
|
final storedSecret = await _secureStorage.read(
|
||||||
key: _spotifyClientSecretKey,
|
key: _spotifyClientSecretKey,
|
||||||
);
|
);
|
||||||
final prefsSecret = state.spotifyClientSecret;
|
if (storedSecret != null && storedSecret.isNotEmpty) {
|
||||||
|
|
||||||
if ((storedSecret == null || storedSecret.isEmpty) &&
|
|
||||||
prefsSecret.isNotEmpty) {
|
|
||||||
await _secureStorage.write(
|
|
||||||
key: _spotifyClientSecretKey,
|
|
||||||
value: prefsSecret,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
|
|
||||||
? storedSecret
|
|
||||||
: (prefsSecret.isNotEmpty ? prefsSecret : '');
|
|
||||||
|
|
||||||
if (effectiveSecret != state.spotifyClientSecret) {
|
|
||||||
state = state.copyWith(spotifyClientSecret: effectiveSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefsSecret.isNotEmpty) {
|
|
||||||
await _saveSettings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _storeSpotifyClientSecret(String secret) async {
|
|
||||||
if (secret.isEmpty) {
|
|
||||||
await _secureStorage.delete(key: _spotifyClientSecretKey);
|
await _secureStorage.delete(key: _spotifyClientSecretKey);
|
||||||
} else {
|
|
||||||
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _applySpotifyCredentials() async {
|
if (state.metadataSource == 'deezer' &&
|
||||||
if (state.spotifyClientId.isNotEmpty &&
|
state.spotifyClientId.isEmpty &&
|
||||||
state.spotifyClientSecret.isNotEmpty) {
|
state.spotifyClientSecret.isEmpty &&
|
||||||
await PlatformBridge.setSpotifyCredentials(
|
!state.useCustomSpotifyCredentials) {
|
||||||
state.spotifyClientId,
|
return;
|
||||||
state.spotifyClientSecret,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
metadataSource: 'deezer',
|
||||||
|
spotifyClientId: '',
|
||||||
|
spotifyClientSecret: '',
|
||||||
|
useCustomSpotifyCredentials: false,
|
||||||
|
);
|
||||||
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
@@ -396,45 +384,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSpotifyClientId(String clientId) {
|
|
||||||
state = state.copyWith(spotifyClientId: clientId);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setSpotifyClientSecret(String clientSecret) async {
|
|
||||||
state = state.copyWith(spotifyClientSecret: clientSecret);
|
|
||||||
await _storeSpotifyClientSecret(clientSecret);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setSpotifyCredentials(
|
|
||||||
String clientId,
|
|
||||||
String clientSecret,
|
|
||||||
) async {
|
|
||||||
state = state.copyWith(
|
|
||||||
spotifyClientId: clientId,
|
|
||||||
spotifyClientSecret: clientSecret,
|
|
||||||
);
|
|
||||||
await _storeSpotifyClientSecret(clientSecret);
|
|
||||||
_saveSettings();
|
|
||||||
_applySpotifyCredentials();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearSpotifyCredentials() async {
|
|
||||||
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
|
|
||||||
await _storeSpotifyClientSecret('');
|
|
||||||
_saveSettings();
|
|
||||||
_applySpotifyCredentials();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setUseCustomSpotifyCredentials(bool enabled) {
|
|
||||||
state = state.copyWith(useCustomSpotifyCredentials: enabled);
|
|
||||||
_saveSettings();
|
|
||||||
_applySpotifyCredentials();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setMetadataSource(String source) {
|
void setMetadataSource(String source) {
|
||||||
state = state.copyWith(metadataSource: source);
|
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
|
||||||
|
state = state.copyWith(metadataSource: normalized);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +484,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLocalLibraryBookmark(String bookmark) {
|
||||||
|
state = state.copyWith(localLibraryBookmark: bookmark);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLocalLibraryPathAndBookmark(String path, String bookmark) {
|
||||||
|
state = state.copyWith(
|
||||||
|
localLibraryPath: path,
|
||||||
|
localLibraryBookmark: bookmark,
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setLocalLibraryShowDuplicates(bool show) {
|
void setLocalLibraryShowDuplicates(bool show) {
|
||||||
state = state.copyWith(localLibraryShowDuplicates: show);
|
state = state.copyWith(localLibraryShowDuplicates: show);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
|
|
||||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
@@ -215,7 +214,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
result = await PlatformBridge.handleURLWithExtension(url);
|
result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
// Check if we got valid data
|
|
||||||
if (result != null &&
|
if (result != null &&
|
||||||
result['type'] == 'track' &&
|
result['type'] == 'track' &&
|
||||||
result['track'] != null) {
|
result['track'] != null) {
|
||||||
@@ -321,7 +319,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Try Deezer URL parsing
|
|
||||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||||
_log.i('Detected Deezer URL, parsing...');
|
_log.i('Detected Deezer URL, parsing...');
|
||||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||||
@@ -387,7 +384,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Try Tidal URL parsing
|
|
||||||
if (url.contains('tidal.com')) {
|
if (url.contains('tidal.com')) {
|
||||||
_log.i('Detected Tidal URL, parsing...');
|
_log.i('Detected Tidal URL, parsing...');
|
||||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||||
@@ -461,7 +457,20 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Fall back to Spotify parsing
|
// If URL doesn't match any known service, it's unrecognized
|
||||||
|
final isSpotifyUrl =
|
||||||
|
url.contains('open.spotify.com') ||
|
||||||
|
url.contains('spotify.link') ||
|
||||||
|
url.startsWith('spotify:');
|
||||||
|
if (!isSpotifyUrl) {
|
||||||
|
state = TrackState(
|
||||||
|
isLoading: false,
|
||||||
|
error: 'url_not_recognized',
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
@@ -538,11 +547,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(
|
Future<void> search(String query, {String? filterOverride}) async {
|
||||||
String query, {
|
|
||||||
String? metadataSource,
|
|
||||||
String? filterOverride,
|
|
||||||
}) async {
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
// Preserve selected filter during loading
|
||||||
@@ -568,7 +573,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
searchProvider != null &&
|
searchProvider != null &&
|
||||||
searchProvider.isNotEmpty;
|
searchProvider.isNotEmpty;
|
||||||
|
|
||||||
final source = metadataSource ?? 'deezer';
|
const source = 'deezer';
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||||
@@ -594,32 +599,20 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Extension search failed, falling back to built-in: $e');
|
_log.w('Extension search failed, falling back to Deezer: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source == 'deezer') {
|
_log.d('Calling Deezer search API...');
|
||||||
_log.d('Calling Deezer search API...');
|
results = await PlatformBridge.searchDeezerAll(
|
||||||
results = await PlatformBridge.searchDeezerAll(
|
query,
|
||||||
query,
|
trackLimit: 20,
|
||||||
trackLimit: 20,
|
artistLimit: 2,
|
||||||
artistLimit: 2,
|
filter: currentFilter,
|
||||||
filter: currentFilter,
|
);
|
||||||
);
|
_log.i(
|
||||||
_log.i(
|
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||||
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_log.d('Calling Spotify search API...');
|
|
||||||
results = await PlatformBridge.searchSpotifyAll(
|
|
||||||
query,
|
|
||||||
trackLimit: 20,
|
|
||||||
artistLimit: 2,
|
|
||||||
);
|
|
||||||
_log.i(
|
|
||||||
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
_log.w('Search request cancelled (requestId=$requestId)');
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
@@ -823,6 +816,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
discNumber: track.discNumber,
|
discNumber: track.discNumber,
|
||||||
releaseDate: track.releaseDate,
|
releaseDate: track.releaseDate,
|
||||||
albumType: track.albumType,
|
albumType: track.albumType,
|
||||||
|
totalTracks: track.totalTracks,
|
||||||
source: track.source,
|
source: track.source,
|
||||||
availability: ServiceAvailability(
|
availability: ServiceAvailability(
|
||||||
tidal: availability['tidal'] as bool? ?? false,
|
tidal: availability['tidal'] as bool? ?? false,
|
||||||
@@ -904,6 +898,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
|
albumType: data['album_type'] as String?,
|
||||||
|
totalTracks: data['total_tracks'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,6 +922,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
totalTracks: data['total_tracks'] as int?,
|
||||||
source:
|
source:
|
||||||
source ??
|
source ??
|
||||||
data['source']?.toString() ??
|
data['source']?.toString() ??
|
||||||
|
|||||||
@@ -224,6 +224,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
|
albumType: data['album_type'] as String?,
|
||||||
|
totalTracks: data['total_tracks'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +307,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Full-screen cover background (no blur, full resolution)
|
|
||||||
if (widget.coverUrl != null)
|
if (widget.coverUrl != null)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl:
|
imageUrl:
|
||||||
@@ -326,7 +327,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Bottom gradient for readability
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -345,7 +345,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Album info overlay at bottom
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
@@ -491,6 +490,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
+173
-139
@@ -21,7 +21,6 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
|||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
|
|
||||||
/// Simple in-memory cache for artist data
|
|
||||||
class _ArtistCache {
|
class _ArtistCache {
|
||||||
static final Map<String, _CacheEntry> _cache = {};
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
static const Duration _ttl = Duration(minutes: 10);
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
@@ -69,7 +68,6 @@ class _CacheEntry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Artist screen with Spotify-like design
|
|
||||||
class ArtistScreen extends ConsumerStatefulWidget {
|
class ArtistScreen extends ConsumerStatefulWidget {
|
||||||
final String artistId;
|
final String artistId;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
@@ -296,7 +294,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(Map<String, dynamic> data, {ArtistAlbum? album}) {
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration_ms'];
|
final durationValue = data['duration_ms'];
|
||||||
if (durationValue is int) {
|
if (durationValue is int) {
|
||||||
@@ -309,18 +307,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
|
||||||
albumArtist: data['album_artist']?.toString(),
|
.toString(),
|
||||||
|
albumArtist: data['album_artist']?.toString() ?? widget.artistName,
|
||||||
artistId:
|
artistId:
|
||||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||||
widget.artistId,
|
widget.artistId,
|
||||||
albumId: data['album_id']?.toString(),
|
albumId: data['album_id']?.toString() ?? album?.id,
|
||||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
|
||||||
|
?.toString(),
|
||||||
isrc: data['isrc']?.toString(),
|
isrc: data['isrc']?.toString(),
|
||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
albumType: data['album_type']?.toString() ?? album?.albumType,
|
||||||
|
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
||||||
source: data['provider_id']?.toString(),
|
source: data['provider_id']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -345,7 +347,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
.where((a) => a.albumType == 'album')
|
.where((a) => a.albumType == 'album')
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
_singlesBucket = albums
|
_singlesBucket = albums
|
||||||
.where((a) => a.albumType == 'single')
|
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
_compilationsBucket = albums
|
_compilationsBucket = albums
|
||||||
.where((a) => a.albumType == 'compilation')
|
.where((a) => a.albumType == 'compilation')
|
||||||
@@ -416,6 +418,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
context.l10n.artistSingles,
|
context.l10n.artistSingles,
|
||||||
singles,
|
singles,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
showTypeBadge: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (compilations.isNotEmpty)
|
if (compilations.isNotEmpty)
|
||||||
@@ -670,7 +673,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
List<ArtistAlbum> albums,
|
List<ArtistAlbum> albums,
|
||||||
) {
|
) {
|
||||||
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
final singles = albums
|
||||||
|
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
|
||||||
|
.toList();
|
||||||
|
|
||||||
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||||
final albumTracks = albumsOnly.fold<int>(
|
final albumTracks = albumsOnly.fold<int>(
|
||||||
@@ -717,7 +722,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
// Options
|
|
||||||
if (albums.isNotEmpty)
|
if (albums.isNotEmpty)
|
||||||
_DiscographyOptionTile(
|
_DiscographyOptionTile(
|
||||||
icon: Icons.library_music,
|
icon: Icons.library_music,
|
||||||
@@ -830,7 +834,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
|
||||||
for (final album in albums) {
|
for (final album in albums) {
|
||||||
if (!_isFetchingDiscography) break; // Cancelled
|
if (!_isFetchingDiscography) break;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final tracks = await _fetchAlbumTracks(album);
|
final tracks = await _fetchAlbumTracks(album);
|
||||||
@@ -942,7 +946,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (result != null && result['tracks'] != null) {
|
if (result != null && result['tracks'] != null) {
|
||||||
final tracksList = result['tracks'] as List<dynamic>;
|
final tracksList = result['tracks'] as List<dynamic>;
|
||||||
return tracksList
|
return tracksList
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
} else if (album.id.startsWith('deezer:')) {
|
} else if (album.id.startsWith('deezer:')) {
|
||||||
@@ -963,7 +967,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (result != null && result['tracks'] != null) {
|
if (result != null && result['tracks'] != null) {
|
||||||
final tracksList = result['tracks'] as List<dynamic>;
|
final tracksList = result['tracks'] as List<dynamic>;
|
||||||
return tracksList
|
return tracksList
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +976,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (metadata['tracks'] != null) {
|
if (metadata['tracks'] != null) {
|
||||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
final tracksList = metadata['tracks'] as List<dynamic>;
|
||||||
return tracksList
|
return tracksList
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1010,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
||||||
releaseDate: album.releaseDate,
|
releaseDate: album.releaseDate,
|
||||||
albumType: album.albumType,
|
albumType: album.albumType,
|
||||||
|
totalTracks: album.totalTracks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1071,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
alignment: Alignment.topCenter, // Show top of image (faces)
|
alignment: Alignment.topCenter,
|
||||||
memCacheWidth: 800,
|
memCacheWidth: 800,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (context, url) =>
|
placeholder: (context, url) =>
|
||||||
@@ -1155,7 +1160,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Download Discography button (icon only, right-aligned)
|
|
||||||
if (hasDiscography && !_isSelectionMode) ...[
|
if (hasDiscography && !_isSelectionMode) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
@@ -1188,6 +1192,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1201,7 +1206,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build Popular tracks section like Spotify
|
|
||||||
Widget _buildPopularSection(ColorScheme colorScheme) {
|
Widget _buildPopularSection(ColorScheme colorScheme) {
|
||||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -1416,7 +1420,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle tap on popular track item
|
|
||||||
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
|
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
|
||||||
if (isQueued) return;
|
if (isQueued) return;
|
||||||
|
|
||||||
@@ -1528,8 +1531,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Widget _buildAlbumSection(
|
Widget _buildAlbumSection(
|
||||||
String title,
|
String title,
|
||||||
List<ArtistAlbum> albums,
|
List<ArtistAlbum> albums,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme, {
|
||||||
) {
|
bool showTypeBadge = false,
|
||||||
|
}) {
|
||||||
final sectionHeight = _artistAlbumSectionHeight();
|
final sectionHeight = _artistAlbumSectionHeight();
|
||||||
final tileSize = _artistAlbumTileSize();
|
final tileSize = _artistAlbumTileSize();
|
||||||
|
|
||||||
@@ -1560,6 +1564,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
colorScheme,
|
colorScheme,
|
||||||
tileSize: tileSize,
|
tileSize: tileSize,
|
||||||
sectionHeight: sectionHeight,
|
sectionHeight: sectionHeight,
|
||||||
|
showTypeBadge: showTypeBadge,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1574,47 +1579,65 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
ColorScheme colorScheme, {
|
ColorScheme colorScheme, {
|
||||||
required double tileSize,
|
required double tileSize,
|
||||||
required double sectionHeight,
|
required double sectionHeight,
|
||||||
|
bool showTypeBadge = false,
|
||||||
}) {
|
}) {
|
||||||
final isSelected = _selectedAlbumIds.contains(album.id);
|
final isSelected = _selectedAlbumIds.contains(album.id);
|
||||||
|
|
||||||
return GestureDetector(
|
return Semantics(
|
||||||
onTap: () {
|
button: true,
|
||||||
if (_isSelectionMode) {
|
selected: _isSelectionMode && isSelected,
|
||||||
_toggleAlbumSelection(album.id);
|
label: _isSelectionMode
|
||||||
} else {
|
? 'Select album ${album.name}'
|
||||||
_navigateToAlbum(album);
|
: 'Open album ${album.name}',
|
||||||
}
|
child: GestureDetector(
|
||||||
},
|
onTap: () {
|
||||||
onLongPress: () {
|
if (_isSelectionMode) {
|
||||||
if (!_isSelectionMode) {
|
_toggleAlbumSelection(album.id);
|
||||||
_enterSelectionMode(album.id);
|
} else {
|
||||||
}
|
_navigateToAlbum(album);
|
||||||
},
|
}
|
||||||
child: Container(
|
},
|
||||||
width: tileSize,
|
onLongPress: () {
|
||||||
height: sectionHeight,
|
if (!_isSelectionMode) {
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
_enterSelectionMode(album.id);
|
||||||
child: Column(
|
}
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
},
|
||||||
children: [
|
child: Container(
|
||||||
Stack(
|
width: tileSize,
|
||||||
children: [
|
height: sectionHeight,
|
||||||
ClipRRect(
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: Column(
|
||||||
child: album.coverUrl != null
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: album.coverUrl!,
|
Stack(
|
||||||
width: tileSize,
|
children: [
|
||||||
height: tileSize,
|
ClipRRect(
|
||||||
fit: BoxFit.cover,
|
borderRadius: BorderRadius.circular(8),
|
||||||
memCacheWidth: (tileSize * 2).round(),
|
child: album.coverUrl != null
|
||||||
cacheManager: CoverCacheManager.instance,
|
? CachedNetworkImage(
|
||||||
placeholder: (context, url) => Container(
|
imageUrl: album.coverUrl!,
|
||||||
width: tileSize,
|
width: tileSize,
|
||||||
height: tileSize,
|
height: tileSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
fit: BoxFit.cover,
|
||||||
),
|
memCacheWidth: (tileSize * 2).round(),
|
||||||
errorWidget: (context, url, error) => Container(
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
width: tileSize,
|
||||||
|
height: tileSize,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
width: tileSize,
|
||||||
|
height: tileSize,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
width: tileSize,
|
width: tileSize,
|
||||||
height: tileSize,
|
height: tileSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
@@ -1624,99 +1647,110 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: Container(
|
if (_isSelectionMode)
|
||||||
width: tileSize,
|
Positioned.fill(
|
||||||
height: tileSize,
|
child: AnimatedContainer(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
duration: const Duration(milliseconds: 200),
|
||||||
child: Icon(
|
decoration: BoxDecoration(
|
||||||
Icons.album,
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: isSelected
|
||||||
size: 40,
|
? colorScheme.primary.withValues(alpha: 0.3)
|
||||||
|
: Colors.black.withValues(alpha: 0.1),
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: colorScheme.primary, width: 3)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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
|
||||||
// Selection overlay
|
? Icon(
|
||||||
if (_isSelectionMode)
|
Icons.check,
|
||||||
Positioned.fill(
|
color: colorScheme.onPrimary,
|
||||||
child: AnimatedContainer(
|
size: 18,
|
||||||
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,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (showTypeBadge)
|
||||||
// Checkbox
|
Positioned(
|
||||||
if (_isSelectionMode)
|
left: 6,
|
||||||
Positioned(
|
bottom: 6,
|
||||||
top: 8,
|
child: Container(
|
||||||
right: 8,
|
padding: const EdgeInsets.symmetric(
|
||||||
child: AnimatedContainer(
|
horizontal: 6,
|
||||||
duration: const Duration(milliseconds: 200),
|
vertical: 2,
|
||||||
width: 28,
|
),
|
||||||
height: 28,
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
color: isSelected
|
borderRadius: BorderRadius.circular(4),
|
||||||
? colorScheme.primary
|
),
|
||||||
: colorScheme.surface.withValues(alpha: 0.9),
|
child: Text(
|
||||||
shape: BoxShape.circle,
|
album.albumType == 'ep' ? 'EP' : 'Single',
|
||||||
border: Border.all(
|
style: const TextStyle(
|
||||||
color: isSelected
|
color: Colors.white,
|
||||||
? colorScheme.primary
|
fontSize: 10,
|
||||||
: colorScheme.outline,
|
fontWeight: FontWeight.w600,
|
||||||
width: 2,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 18,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
album.name,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
album.totalTracks > 0
|
|
||||||
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
|
||||||
: album.releaseDate.length >= 4
|
|
||||||
? album.releaseDate.substring(0, 4)
|
|
||||||
: album.releaseDate,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
],
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
album.name,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
album.totalTracks > 0
|
||||||
|
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
||||||
|
: album.releaseDate.length >= 4
|
||||||
|
? album.releaseDate.substring(0, 4)
|
||||||
|
: album.releaseDate,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
|
|
||||||
/// Screen to display downloaded tracks from a specific album
|
|
||||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
final String albumName;
|
final String albumName;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
@@ -361,7 +360,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
final tracks = _getAlbumTracks(allHistoryItems);
|
final tracks = _getAlbumTracks(allHistoryItems);
|
||||||
|
|
||||||
// Show empty state if no tracks found
|
|
||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.albumName)),
|
appBar: AppBar(title: Text(widget.albumName)),
|
||||||
@@ -480,7 +478,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Full-screen cover background
|
|
||||||
if (embeddedCoverPath != null)
|
if (embeddedCoverPath != null)
|
||||||
Image.file(
|
Image.file(
|
||||||
File(embeddedCoverPath),
|
File(embeddedCoverPath),
|
||||||
@@ -508,7 +505,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Bottom gradient for readability
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Album info overlay at bottom
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
@@ -635,6 +630,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -711,10 +707,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final discTracks = discMap[discNumber];
|
final discTracks = discMap[discNumber];
|
||||||
if (discTracks == null || discTracks.isEmpty) continue;
|
if (discTracks == null || discTracks.isEmpty) continue;
|
||||||
|
|
||||||
// Add disc separator
|
|
||||||
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
|
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
|
||||||
|
|
||||||
// Add tracks for this disc
|
|
||||||
for (final track in discTracks) {
|
for (final track in discTracks) {
|
||||||
children.add(
|
children.add(
|
||||||
KeyedSubtree(
|
KeyedSubtree(
|
||||||
@@ -858,6 +852,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
trailing: _isSelectionMode
|
trailing: _isSelectionMode
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
|
tooltip: 'Play track',
|
||||||
onPressed: () => _openFile(track),
|
onPressed: () => _openFile(track),
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
@@ -897,7 +892,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Share SAF content URIs via native intent
|
|
||||||
if (safUris.isNotEmpty) {
|
if (safUris.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
if (safUris.length == 1) {
|
if (safUris.length == 1) {
|
||||||
@@ -908,13 +902,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Share regular files via SharePlus
|
|
||||||
if (filesToShare.isNotEmpty) {
|
if (filesToShare.isNotEmpty) {
|
||||||
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show batch convert bottom sheet
|
|
||||||
void _showBatchConvertSheet(
|
void _showBatchConvertSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<DownloadHistoryItem> allTracks,
|
List<DownloadHistoryItem> allTracks,
|
||||||
@@ -1336,6 +1328,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: _exitSelectionMode,
|
onPressed: _exitSelectionMode,
|
||||||
|
tooltip: MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).closeButtonTooltip,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
@@ -1388,7 +1383,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Action buttons row: Share, Convert
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
+188
-132
@@ -520,7 +520,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
final searchProvider = settings.searchProvider;
|
final searchProvider = settings.searchProvider;
|
||||||
// Use filterOverride if provided, otherwise read from state
|
|
||||||
final selectedFilter =
|
final selectedFilter =
|
||||||
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
|
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
|
||||||
|
|
||||||
@@ -535,7 +534,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||||
|
|
||||||
if (isExtensionEnabled) {
|
if (isExtensionEnabled) {
|
||||||
// Build options with filter if selected
|
|
||||||
Map<String, dynamic>? options;
|
Map<String, dynamic>? options;
|
||||||
if (selectedFilter != null) {
|
if (selectedFilter != null) {
|
||||||
options = {'filter': selectedFilter};
|
options = {'filter': selectedFilter};
|
||||||
@@ -551,11 +549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
}
|
}
|
||||||
await ref
|
await ref
|
||||||
.read(trackProvider.notifier)
|
.read(trackProvider.notifier)
|
||||||
.search(
|
.search(query, filterOverride: selectedFilter);
|
||||||
query,
|
|
||||||
metadataSource: settings.metadataSource,
|
|
||||||
filterOverride: selectedFilter,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
@@ -585,12 +579,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (url.isEmpty) return;
|
if (url.isEmpty) return;
|
||||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
_navigateToDetailIfNeeded();
|
final trackState = ref.read(trackProvider);
|
||||||
|
if (trackState.error != null && mounted) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final errorMsg = trackState.error!;
|
||||||
|
final isRateLimit =
|
||||||
|
errorMsg.contains('429') ||
|
||||||
|
errorMsg.toLowerCase().contains('rate limit') ||
|
||||||
|
errorMsg.toLowerCase().contains('too many requests');
|
||||||
|
final displayMessage = errorMsg == 'url_not_recognized'
|
||||||
|
? l10n.errorUrlNotRecognizedMessage
|
||||||
|
: isRateLimit
|
||||||
|
? l10n.errorRateLimitedMessage
|
||||||
|
: l10n.errorUrlFetchFailed;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(displayMessage)));
|
||||||
|
ref.read(trackProvider.notifier).clear();
|
||||||
|
} else {
|
||||||
|
_navigateToDetailIfNeeded();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final settings = ref.read(settingsProvider);
|
await ref.read(trackProvider.notifier).search(url);
|
||||||
await ref
|
|
||||||
.read(trackProvider.notifier)
|
|
||||||
.search(url, metadataSource: settings.metadataSource);
|
|
||||||
}
|
}
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1126,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Search filter bar (only shown when has search results)
|
|
||||||
if (hasActualResults && !showRecentAccess)
|
if (hasActualResults && !showRecentAccess)
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
@@ -1265,7 +1274,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
(searchArtists != null && searchArtists.isNotEmpty) ||
|
(searchArtists != null && searchArtists.isNotEmpty) ||
|
||||||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
|
(searchAlbums != null && searchAlbums.isNotEmpty) ||
|
||||||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
|
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
|
||||||
isLoading;
|
isLoading ||
|
||||||
|
error != null;
|
||||||
|
|
||||||
return SliverMainAxisGroup(
|
return SliverMainAxisGroup(
|
||||||
slivers: _buildSearchResults(
|
slivers: _buildSearchResults(
|
||||||
@@ -1286,8 +1296,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
), // Close RefreshIndicator
|
),
|
||||||
), // Close GestureDetector
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1335,24 +1345,49 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
child: GestureDetector(
|
child: Semantics(
|
||||||
onTap: () => _navigateToMetadataScreen(item),
|
button: true,
|
||||||
child: Container(
|
label: 'Open track ${item.trackName} by ${item.artistName}',
|
||||||
width: coverSize,
|
child: GestureDetector(
|
||||||
margin: const EdgeInsets.only(right: 12),
|
onTap: () => _navigateToMetadataScreen(item),
|
||||||
child: Column(
|
child: Container(
|
||||||
children: [
|
width: coverSize,
|
||||||
ClipRRect(
|
margin: const EdgeInsets.only(right: 12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Column(
|
||||||
child: embeddedCoverPath != null
|
children: [
|
||||||
? Image.file(
|
ClipRRect(
|
||||||
File(embeddedCoverPath),
|
borderRadius: BorderRadius.circular(12),
|
||||||
width: coverSize,
|
child: embeddedCoverPath != null
|
||||||
height: coverSize,
|
? Image.file(
|
||||||
fit: BoxFit.cover,
|
File(embeddedCoverPath),
|
||||||
cacheWidth: (coverSize * 2).round(),
|
width: coverSize,
|
||||||
cacheHeight: (coverSize * 2).round(),
|
height: coverSize,
|
||||||
errorBuilder: (_, _, _) => Container(
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: (coverSize * 2).round(),
|
||||||
|
cacheHeight: (coverSize * 2).round(),
|
||||||
|
errorBuilder: (_, _, _) => Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
color:
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: item.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: (coverSize * 2).round(),
|
||||||
|
memCacheHeight: (coverSize * 2).round(),
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
@@ -1362,38 +1397,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: item.coverUrl != null
|
const SizedBox(height: 6),
|
||||||
? CachedNetworkImage(
|
Text(
|
||||||
imageUrl: item.coverUrl!,
|
item.trackName,
|
||||||
width: coverSize,
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
height: coverSize,
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
fit: BoxFit.cover,
|
maxLines: 1,
|
||||||
memCacheWidth: (coverSize * 2).round(),
|
overflow: TextOverflow.ellipsis,
|
||||||
memCacheHeight: (coverSize * 2).round(),
|
textAlign: TextAlign.center,
|
||||||
cacheManager: CoverCacheManager.instance,
|
),
|
||||||
)
|
],
|
||||||
: Container(
|
),
|
||||||
width: coverSize,
|
|
||||||
height: coverSize,
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(
|
|
||||||
Icons.music_note,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
|
||||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1434,7 +1449,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom padding
|
|
||||||
return const SizedBox(height: 16);
|
return const SizedBox(height: 16);
|
||||||
}, childCount: totalCount),
|
}, childCount: totalCount),
|
||||||
),
|
),
|
||||||
@@ -1498,31 +1512,45 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
final cardSize = _exploreCardSize(context);
|
final cardSize = _exploreCardSize(context);
|
||||||
final iconSize = cardSize * 0.3;
|
final iconSize = cardSize * 0.3;
|
||||||
|
|
||||||
return GestureDetector(
|
return Semantics(
|
||||||
onTap: () => _navigateToExploreItem(item),
|
button: true,
|
||||||
child: SizedBox(
|
label: 'Open ${item.type} ${item.name}',
|
||||||
width: cardSize,
|
child: GestureDetector(
|
||||||
child: Padding(
|
onTap: () => _navigateToExploreItem(item),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
child: SizedBox(
|
||||||
child: Column(
|
width: cardSize,
|
||||||
crossAxisAlignment: isArtist
|
child: Padding(
|
||||||
? CrossAxisAlignment.center
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: isArtist
|
||||||
ClipRRect(
|
? CrossAxisAlignment.center
|
||||||
borderRadius: BorderRadius.circular(
|
: CrossAxisAlignment.start,
|
||||||
isArtist ? cardSize / 2 : 8,
|
children: [
|
||||||
),
|
ClipRRect(
|
||||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
borderRadius: BorderRadius.circular(
|
||||||
? CachedNetworkImage(
|
isArtist ? cardSize / 2 : 8,
|
||||||
imageUrl: item.coverUrl!,
|
),
|
||||||
width: cardSize,
|
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||||
height: cardSize,
|
? CachedNetworkImage(
|
||||||
fit: BoxFit.cover,
|
imageUrl: item.coverUrl!,
|
||||||
memCacheWidth: (cardSize * 2).round(),
|
width: cardSize,
|
||||||
memCacheHeight: (cardSize * 2).round(),
|
height: cardSize,
|
||||||
cacheManager: CoverCacheManager.instance,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (context, url, error) => Container(
|
memCacheWidth: (cardSize * 2).round(),
|
||||||
|
memCacheHeight: (cardSize * 2).round(),
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
width: cardSize,
|
||||||
|
height: cardSize,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
_getIconForType(item.type),
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
width: cardSize,
|
width: cardSize,
|
||||||
height: cardSize,
|
height: cardSize,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
@@ -1532,42 +1560,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
size: iconSize,
|
size: iconSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: cardSize,
|
|
||||||
height: cardSize,
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(
|
|
||||||
_getIconForType(item.type),
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
size: iconSize,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
if (item.artists.isNotEmpty && !isArtist)
|
Text(
|
||||||
ClickableArtistName(
|
item.name,
|
||||||
artistName: item.artists,
|
|
||||||
coverUrl: item.coverUrl,
|
|
||||||
extensionId: item.providerId,
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
fontWeight: FontWeight.w500,
|
||||||
fontSize: 11,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (item.artists.isNotEmpty && !isArtist)
|
||||||
|
ClickableArtistName(
|
||||||
|
artistName: item.artists,
|
||||||
|
coverUrl: item.coverUrl,
|
||||||
|
extensionId: item.providerId,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2022,6 +2040,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Dismiss',
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.close,
|
Icons.close,
|
||||||
size: 20,
|
size: 20,
|
||||||
@@ -2218,11 +2237,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final l10n = context.l10n;
|
||||||
final isRateLimit =
|
final isRateLimit =
|
||||||
error.contains('429') ||
|
error.contains('429') ||
|
||||||
error.toLowerCase().contains('rate limit') ||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
error.toLowerCase().contains('too many requests');
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
final isUrlNotRecognized = error == 'url_not_recognized';
|
||||||
|
|
||||||
if (isRateLimit) {
|
if (isRateLimit) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -2239,7 +2261,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Rate Limited',
|
l10n.errorRateLimited,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -2247,7 +2269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Too many requests. Please wait a moment before searching again.',
|
l10n.errorRateLimitedMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -2262,6 +2284,42 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isUrlNotRecognized) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.link_off, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.errorUrlNotRecognized,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.errorUrlNotRecognizedMessage,
|
||||||
|
style: TextStyle(color: colorScheme.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
@@ -2273,7 +2331,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
Icon(Icons.error_outline, color: colorScheme.error),
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
child: Text(
|
||||||
|
l10n.errorUrlFetchFailed,
|
||||||
|
style: TextStyle(color: colorScheme.error),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -2705,7 +2766,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// "All" chip (no filter)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
@@ -2728,7 +2788,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Filter chips from extension
|
|
||||||
...filters.map((filter) {
|
...filters.map((filter) {
|
||||||
final isSelected = selectedFilter == filter.id;
|
final isSelected = selectedFilter == filter.id;
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -2830,7 +2889,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
prefixIcon: _SearchProviderDropdown(
|
prefixIcon: _SearchProviderDropdown(
|
||||||
onProviderChanged: () {
|
onProviderChanged: () {
|
||||||
_lastSearchQuery = null;
|
_lastSearchQuery = null;
|
||||||
// Reset filter when provider changes
|
|
||||||
ref.read(trackProvider.notifier).setSearchFilter(null);
|
ref.read(trackProvider.notifier).setSearchFilter(null);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
final text = _urlController.text.trim();
|
final text = _urlController.text.trim();
|
||||||
@@ -2904,9 +2962,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
final currentProvider = ref.watch(
|
final currentProvider = ref.watch(
|
||||||
settingsProvider.select((s) => s.searchProvider),
|
settingsProvider.select((s) => s.searchProvider),
|
||||||
);
|
);
|
||||||
final metadataSource = ref.watch(
|
|
||||||
settingsProvider.select((s) => s.metadataSource),
|
|
||||||
);
|
|
||||||
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
|
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -2984,7 +3039,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
metadataSource == 'spotify' ? 'Spotify' : 'Deezer',
|
'Deezer',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight:
|
fontWeight:
|
||||||
currentProvider == null || currentProvider.isEmpty
|
currentProvider == null || currentProvider.isEmpty
|
||||||
@@ -4386,6 +4441,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.more_vert,
|
Icons.more_vert,
|
||||||
color: widget.colorScheme.onSurfaceVariant,
|
color: widget.colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
@@ -158,7 +159,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Header: drag handle + thumbnail + playlist info
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -210,7 +210,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Rename
|
|
||||||
_PlaylistOptionTile(
|
_PlaylistOptionTile(
|
||||||
icon: Icons.edit_outlined,
|
icon: Icons.edit_outlined,
|
||||||
title: context.l10n.collectionRenamePlaylist,
|
title: context.l10n.collectionRenamePlaylist,
|
||||||
@@ -225,7 +224,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Change cover
|
|
||||||
_PlaylistOptionTile(
|
_PlaylistOptionTile(
|
||||||
icon: Icons.image_outlined,
|
icon: Icons.image_outlined,
|
||||||
title: context.l10n.collectionPlaylistChangeCover,
|
title: context.l10n.collectionPlaylistChangeCover,
|
||||||
@@ -235,7 +233,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Delete
|
|
||||||
_PlaylistOptionTile(
|
_PlaylistOptionTile(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
iconColor: colorScheme.error,
|
iconColor: colorScheme.error,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
// ── Multi-select state ──
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedKeys = {};
|
final Set<String> _selectedKeys = {};
|
||||||
|
|
||||||
@@ -145,8 +144,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Selection helpers ──
|
|
||||||
|
|
||||||
void _enterSelectionMode(String key) {
|
void _enterSelectionMode(String key) {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -181,8 +178,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Batch actions ──
|
|
||||||
|
|
||||||
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
|
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
|
||||||
final keysToRemove = _selectedKeys.toSet();
|
final keysToRemove = _selectedKeys.toSet();
|
||||||
if (keysToRemove.isEmpty) return;
|
if (keysToRemove.isEmpty) return;
|
||||||
@@ -426,7 +421,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Drag handle
|
|
||||||
Container(
|
Container(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 4,
|
height: 4,
|
||||||
@@ -437,11 +431,13 @@ class _LibraryTracksFolderScreenState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Header: [X close] [count] [Select All / Deselect]
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: _exitSelectionMode,
|
onPressed: _exitSelectionMode,
|
||||||
|
tooltip: MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).closeButtonTooltip,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
@@ -493,7 +489,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Action buttons row
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWishlist)
|
if (isWishlist)
|
||||||
@@ -525,7 +520,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Remove button (full width, red)
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
@@ -714,7 +708,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
coverFallback,
|
coverFallback,
|
||||||
// Bottom gradient for readability
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -733,7 +726,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Title and track count overlay
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
@@ -811,6 +803,9 @@ class _LibraryTracksFolderScreenState
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
tooltip: _isSelectionMode
|
||||||
|
? MaterialLocalizations.of(context).closeButtonTooltip
|
||||||
|
: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -829,9 +824,8 @@ class _LibraryTracksFolderScreenState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Header actions ──
|
Widget _buildHeaderActionPlaceholder() =>
|
||||||
|
const SizedBox(width: 48, height: 48);
|
||||||
Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48);
|
|
||||||
|
|
||||||
Widget _buildDownloadAllCenterButton(List<CollectionTrackEntry> entries) {
|
Widget _buildDownloadAllCenterButton(List<CollectionTrackEntry> entries) {
|
||||||
final tracks = entries.map((e) => e.track).toList(growable: false);
|
final tracks = entries.map((e) => e.track).toList(growable: false);
|
||||||
@@ -1152,6 +1146,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
trailing: isSelectionMode
|
trailing: isSelectionMode
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.more_vert,
|
Icons.more_vert,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -1263,7 +1258,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Header: drag handle + cover + track info
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
|||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
|
|
||||||
/// Screen to display tracks from a local library album
|
|
||||||
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||||
final String albumName;
|
final String albumName;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
@@ -39,6 +38,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
late List<LocalLibraryItem> _sortedTracksCache;
|
late List<LocalLibraryItem> _sortedTracksCache;
|
||||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||||
|
|
||||||
|
void _showCueVirtualTrackSnackBar() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(cueVirtualTrackRequiresSplitMessage),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
late List<int> _sortedDiscNumbersCache;
|
late List<int> _sortedDiscNumbersCache;
|
||||||
late bool _hasMultipleDiscsCache;
|
late bool _hasMultipleDiscsCache;
|
||||||
String? _commonQualityCache;
|
String? _commonQualityCache;
|
||||||
@@ -83,7 +90,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
List<LocalLibraryItem> _buildSortedTracks() {
|
List<LocalLibraryItem> _buildSortedTracks() {
|
||||||
final tracks = List<LocalLibraryItem>.from(widget.tracks);
|
final tracks = List<LocalLibraryItem>.from(widget.tracks);
|
||||||
tracks.sort((a, b) {
|
tracks.sort((a, b) {
|
||||||
// Sort by disc number first, then by track number
|
|
||||||
final aDisc = a.discNumber ?? 1;
|
final aDisc = a.discNumber ?? 1;
|
||||||
final bDisc = b.discNumber ?? 1;
|
final bDisc = b.discNumber ?? 1;
|
||||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||||
@@ -180,9 +186,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
final item = tracksById[id];
|
final item = tracksById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
if (!isCueVirtualPath(item.filePath)) {
|
||||||
await deleteFile(item.filePath);
|
try {
|
||||||
} catch (_) {}
|
await deleteFile(item.filePath);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
await libraryNotifier.removeItem(id);
|
await libraryNotifier.removeItem(id);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
@@ -197,7 +205,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Go back if all tracks were deleted
|
|
||||||
if (deletedCount == currentTracks.length) {
|
if (deletedCount == currentTracks.length) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
@@ -206,6 +213,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openFile(LocalLibraryItem track) async {
|
Future<void> _openFile(LocalLibraryItem track) async {
|
||||||
|
if (isCueVirtualPath(track.filePath)) {
|
||||||
|
_showCueVirtualTrackSnackBar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await ref
|
await ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackProvider.notifier)
|
||||||
@@ -233,7 +244,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
final tracks = _sortedTracksCache;
|
final tracks = _sortedTracksCache;
|
||||||
|
|
||||||
// Show empty state if no tracks found
|
|
||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.albumName)),
|
appBar: AppBar(title: Text(widget.albumName)),
|
||||||
@@ -326,7 +336,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Full-screen cover background
|
|
||||||
if (widget.coverPath != null)
|
if (widget.coverPath != null)
|
||||||
Image.file(
|
Image.file(
|
||||||
File(widget.coverPath!),
|
File(widget.coverPath!),
|
||||||
@@ -343,7 +352,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Bottom gradient for readability
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -362,7 +370,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Album info overlay at bottom
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
@@ -494,6 +501,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -733,6 +741,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
trailing: _isSelectionMode
|
trailing: _isSelectionMode
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
|
tooltip: 'Play track',
|
||||||
onPressed: () => _openFile(track),
|
onPressed: () => _openFile(track),
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
@@ -888,7 +897,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Batch re-enrich selected local tracks
|
|
||||||
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final selected = <LocalLibraryItem>[];
|
final selected = <LocalLibraryItem>[];
|
||||||
@@ -958,13 +966,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim();
|
final settings = ref.read(settingsProvider);
|
||||||
|
final localLibraryPath = settings.localLibraryPath.trim();
|
||||||
|
final iosBookmark = settings.localLibraryBookmark;
|
||||||
try {
|
try {
|
||||||
if (localLibraryPath.isNotEmpty &&
|
if (localLibraryPath.isNotEmpty &&
|
||||||
!ref.read(localLibraryProvider).isScanning) {
|
!ref.read(localLibraryProvider).isScanning) {
|
||||||
await ref
|
await ref
|
||||||
.read(localLibraryProvider.notifier)
|
.read(localLibraryProvider.notifier)
|
||||||
.startScan(localLibraryPath);
|
.startScan(
|
||||||
|
localLibraryPath,
|
||||||
|
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
}
|
}
|
||||||
@@ -988,7 +1001,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
).showSnackBar(SnackBar(content: Text(summary)));
|
).showSnackBar(SnackBar(content: Text(summary)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show batch convert bottom sheet
|
|
||||||
void _showBatchConvertSheet(
|
void _showBatchConvertSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<LocalLibraryItem> allTracks,
|
List<LocalLibraryItem> allTracks,
|
||||||
@@ -1261,7 +1273,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
String? safTempPath;
|
String? safTempPath;
|
||||||
|
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
// Copy SAF file to temp for conversion
|
|
||||||
safTempPath = await PlatformBridge.copyContentUriToTemp(
|
safTempPath = await PlatformBridge.copyContentUriToTemp(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
);
|
);
|
||||||
@@ -1296,7 +1307,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
// For SAF: derive the parent tree URI and relative dir from the content URI,
|
// For SAF: derive the parent tree URI and relative dir from the content URI,
|
||||||
// then create new SAF file and delete old one
|
// then create new SAF file and delete old one
|
||||||
//
|
|
||||||
// Parse the SAF URI to get the tree document path:
|
// Parse the SAF URI to get the tree document path:
|
||||||
// content://...tree/...document/.../oldName.flac
|
// content://...tree/...document/.../oldName.flac
|
||||||
// We need tree URI and relative dir to create the new file
|
// We need tree URI and relative dir to create the new file
|
||||||
@@ -1375,14 +1385,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete old SAF file
|
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.safDelete(item.filePath);
|
await PlatformBridge.safDelete(item.filePath);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await localDb.deleteByPath(item.filePath);
|
await localDb.deleteByPath(item.filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up temp files
|
|
||||||
try {
|
try {
|
||||||
await File(newPath).delete();
|
await File(newPath).delete();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -1400,7 +1408,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload local library to pick up converted files
|
|
||||||
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
_exitSelectionMode();
|
_exitSelectionMode();
|
||||||
|
|
||||||
@@ -1461,6 +1468,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: _exitSelectionMode,
|
onPressed: _exitSelectionMode,
|
||||||
|
tooltip: MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).closeButtonTooltip,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
@@ -1513,7 +1523,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Action buttons row: Re-enrich, Convert
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -102,13 +102,29 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
}
|
}
|
||||||
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
|
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
|
||||||
}
|
}
|
||||||
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
|
final trackState = ref.read(trackProvider);
|
||||||
|
if (trackState.error != null && mounted) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final errorMsg = trackState.error!;
|
||||||
|
final isRateLimit = errorMsg.contains('429') ||
|
||||||
|
errorMsg.toLowerCase().contains('rate limit') ||
|
||||||
|
errorMsg.toLowerCase().contains('too many requests');
|
||||||
|
final displayMessage = errorMsg == 'url_not_recognized'
|
||||||
|
? l10n.errorUrlNotRecognizedMessage
|
||||||
|
: isRateLimit
|
||||||
|
? l10n.errorRateLimitedMessage
|
||||||
|
: l10n.errorUrlFetchFailed;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(displayMessage)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user