Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6388f3a5b8 | |||
| 55b75dc48d | |||
| f6cea1a683 | |||
| 8d205600b8 | |||
| aa35f60fad | |||
| b627ae1874 | |||
| 46afa6e733 | |||
| c01b189477 | |||
| 966935b677 | |||
| f2f8ca4528 | |||
| 7844bd2f42 | |||
| ac3d51e2cd | |||
| b899b54bb8 | |||
| 7a17de49b2 | |||
| 79180dd918 | |||
| e725a7be77 | |||
| d960708dac | |||
| c62ad005f5 | |||
| 68fa1bfdae | |||
| bd6b23400e | |||
| b6d2fea847 | |||
| f356e53f7e | |||
| bb1ff187a3 | |||
| d99a1b1c21 | |||
| c36497e87c | |||
| 03027813c1 | |||
| 8e9d0c3e9a | |||
| 6c8813c9de | |||
| ec314eb479 | |||
| 77e4457244 | |||
| 0119db094d | |||
| 9c35515d6f | |||
| 1546d7da22 | |||
| 61720f3f2a | |||
| 7749399239 | |||
| d143b82068 | |||
| 606e7c1079 | |||
| a650632c4e | |||
| 3c118f74e4 | |||
| bc3055f6e1 | |||
| 7c86ae0b7e | |||
| 595bfb2711 | |||
| 5f39a3d52f | |||
| e7077781e6 | |||
| 42d15db4ca | |||
| c2599981d6 | |||
| a1647a41ff | |||
| bf2fc7702b | |||
| f814408702 | |||
| 6b1958bfd0 | |||
| bc120ffa76 | |||
| 5ea454a0b0 | |||
| da574f895c | |||
| 1c445e91d9 | |||
| 5d03eb0656 | |||
| becb6845a6 | |||
| be3ee3b216 | |||
| 3747674968 | |||
| ff9d088c5f | |||
| 12db11d559 | |||
| 7e1aca33a5 | |||
| 07a1c68354 | |||
| f4d7c6531f | |||
| e9ca054682 | |||
| 1069bdd0d8 | |||
| ff882a58d7 | |||
| dddc8c3d94 | |||
| 720525b67b | |||
| cc12f63d36 | |||
| 5c67553596 |
@@ -0,0 +1,4 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
buy_me_a_coffee: zarzet
|
||||
|
||||
@@ -412,3 +412,133 @@ jobs:
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v}
|
||||
|
||||
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
||||
# Use tr -d '\r' to handle CRLF line endings from Windows
|
||||
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
||||
|
||||
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
||||
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
||||
|
||||
if [ -z "$FULL_CHANGELOG" ]; then
|
||||
CHANGELOG="See release notes on GitHub for details."
|
||||
else
|
||||
# Convert GitHub Markdown to Telegram HTML:
|
||||
# - **text** → <b>text</b>
|
||||
# - `code` → <code>code</code>
|
||||
# - ### Header → <b>Header</b>
|
||||
# - Escape HTML special chars first
|
||||
# - Remove > blockquote prefix
|
||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||
sed 's/^> //' | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^- /• /g' | \
|
||||
sed 's/^ - / ◦ /g')
|
||||
|
||||
# Take first 2500 characters, then cut at last complete line
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
|
||||
# Check if truncated
|
||||
FULL_LEN=${#FULL_CHANGELOG}
|
||||
if [ $FULL_LEN -gt 2500 ]; then
|
||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "DEBUG: Final changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Send to Telegram Channel
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||
|
||||
# Find APK files
|
||||
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||
|
||||
# Prepare message with changelog (HTML format)
|
||||
printf '%s\n' \
|
||||
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||
"" \
|
||||
"<b>What's New:</b>" \
|
||||
"${CHANGELOG}" \
|
||||
"" \
|
||||
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||
> /tmp/telegram_message.txt
|
||||
|
||||
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||
|
||||
# Send message first (using HTML parse mode)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-d text="${MESSAGE}" \
|
||||
-d parse_mode="HTML" \
|
||||
-d disable_web_page_preview="true"
|
||||
|
||||
# Upload arm64 APK to channel
|
||||
if [ -f "$ARM64_APK" ]; then
|
||||
echo "Uploading arm64 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
if [ -f "$ARM32_APK" ]; then
|
||||
echo "Uploading arm32 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
if [ -f "$IOS_IPA" ]; then
|
||||
echo "Uploading iOS IPA to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**[zarzet](https://github.com/zarzet)**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,268 @@
|
||||
# Contributing to SpotiFLAC
|
||||
|
||||
First off, thank you for considering contributing to SpotiFLAC! 🎉
|
||||
|
||||
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [How Can I Contribute?](#how-can-i-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Features](#suggesting-features)
|
||||
- [Code Contributions](#code-contributions)
|
||||
- [Translations](#translations)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Commit Guidelines](#commit-guidelines)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
||||
|
||||
When creating a bug report, please use the bug report template and include:
|
||||
|
||||
- **Clear and descriptive title**
|
||||
- **Steps to reproduce** the issue
|
||||
- **Expected behavior** vs **actual behavior**
|
||||
- **Screenshots or screen recordings** if applicable
|
||||
- **Device information** (model, OS version)
|
||||
- **App version**
|
||||
- **Logs** from Settings > About > View Logs
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
Feature requests are welcome! Please use the feature request template and:
|
||||
|
||||
- **Check existing issues** to avoid duplicates
|
||||
- **Describe the feature** clearly
|
||||
- **Explain the use case** - why would this be useful?
|
||||
- **Consider the scope** - is this a small enhancement or a major feature?
|
||||
|
||||
### Code Contributions
|
||||
|
||||
1. **Fork the repository** and create your branch from `dev`
|
||||
2. **Make your changes** following our coding guidelines
|
||||
3. **Test your changes** thoroughly
|
||||
4. **Submit a pull request** to the `dev` branch
|
||||
|
||||
### Translations
|
||||
|
||||
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
||||
|
||||
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
||||
2. Select your language or request a new one
|
||||
3. Start translating!
|
||||
|
||||
Translation files are located in `lib/l10n/arb/`.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Flutter SDK** 3.10.0 or higher
|
||||
- **Dart SDK** 3.10.0 or higher
|
||||
- **Android Studio** or **VS Code** with Flutter extensions
|
||||
- **Git**
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Clone your fork**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||
cd SpotiFLAC-Mobile
|
||||
```
|
||||
|
||||
2. **Add upstream remote**
|
||||
```bash
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
5. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
flutter build apk --debug
|
||||
|
||||
# Release build
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── l10n/ # Localization files
|
||||
│ └── arb/ # ARB translation files
|
||||
├── models/ # Data models
|
||||
├── providers/ # Riverpod providers
|
||||
├── screens/ # UI screens
|
||||
│ └── settings/ # Settings sub-screens
|
||||
├── services/ # Business logic services
|
||||
├── theme/ # App theming
|
||||
├── utils/ # Utility functions
|
||||
├── widgets/ # Reusable widgets
|
||||
├── app.dart # App configuration
|
||||
└── main.dart # Entry point
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### General
|
||||
|
||||
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions small and focused
|
||||
- Add comments for complex logic
|
||||
|
||||
### Formatting
|
||||
|
||||
- Use `dart format` before committing
|
||||
- Maximum line length: 80 characters
|
||||
- Use trailing commas for better formatting
|
||||
|
||||
```bash
|
||||
dart format .
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
Ensure your code passes all lints:
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
We use **Riverpod** for state management. Follow these patterns:
|
||||
|
||||
```dart
|
||||
// Use code generation with riverpod_annotation
|
||||
@riverpod
|
||||
class MyNotifier extends _$MyNotifier {
|
||||
@override
|
||||
MyState build() => MyState();
|
||||
|
||||
// Methods to update state
|
||||
}
|
||||
```
|
||||
|
||||
### Localization
|
||||
|
||||
All user-facing strings should be localized:
|
||||
|
||||
```dart
|
||||
// Good
|
||||
Text(AppLocalizations.of(context)!.downloadComplete)
|
||||
|
||||
// Bad
|
||||
Text('Download Complete')
|
||||
```
|
||||
|
||||
To add new strings:
|
||||
1. Add the key to `lib/l10n/arb/app_en.arb`
|
||||
2. Run `flutter gen-l10n`
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes (formatting, etc.)
|
||||
- `refactor`: Code refactoring
|
||||
- `perf`: Performance improvements
|
||||
- `test`: Adding or updating tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(download): add batch download support
|
||||
fix(ui): resolve overflow on small screens
|
||||
docs: update contributing guidelines
|
||||
chore(deps): update flutter_riverpod to 3.1.0
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Update your fork**
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/dev
|
||||
```
|
||||
|
||||
2. **Create a feature branch**
|
||||
```bash
|
||||
git checkout -b feat/my-new-feature
|
||||
```
|
||||
|
||||
3. **Make your changes** and commit following our guidelines
|
||||
|
||||
4. **Push to your fork**
|
||||
```bash
|
||||
git push origin feat/my-new-feature
|
||||
```
|
||||
|
||||
5. **Create a Pull Request**
|
||||
- Target the `dev` branch
|
||||
- Fill in the PR template
|
||||
- Link related issues
|
||||
|
||||
6. **Address review feedback**
|
||||
- Make requested changes
|
||||
- Push additional commits
|
||||
- Request re-review when ready
|
||||
|
||||
### PR Requirements
|
||||
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] All tests pass
|
||||
- [ ] No new linting errors
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] Commit messages follow guidelines
|
||||
- [ ] PR description is clear and complete
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, feel free to:
|
||||
|
||||
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
||||
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
||||
|
||||
Thank you for contributing! 💚
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
|
||||
[](https://www.virustotal.com/gui/file/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
@@ -52,6 +52,20 @@ Want to create your own extension? Check out the [Extension Development Guide](h
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
|
||||
|
||||
## Telegram
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/spotiflac">
|
||||
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
||||
</a>
|
||||
|
||||
<a href="https://t.me/spotiflacchat">
|
||||
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
@@ -69,7 +83,8 @@ A: The app needs permission to save downloaded files to your device. On Android
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkDuplicatesBatch" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"preBuildDuplicateIndex" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.preBuildDuplicateIndex(outputDir)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"invalidateDuplicateIndex" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.invalidateDuplicateIndex(outputDir)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"buildFilename" -> {
|
||||
val template = call.argument<String>("template") ?: ""
|
||||
val metadata = call.argument<String>("metadata") ?: "{}"
|
||||
@@ -284,6 +306,13 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerExtendedMetadata" -> {
|
||||
val trackId = call.argument<String>("track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDeezerExtendedMetadata(trackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"convertSpotifyToDeezer" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
@@ -299,6 +328,43 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkAvailabilityFromDeezerID" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkAvailabilityByPlatformID" -> {
|
||||
val platform = call.argument<String>("platform") ?: ""
|
||||
val entityType = call.argument<String>("entity_type") ?: ""
|
||||
val entityId = call.argument<String>("entity_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getSpotifyIDFromDeezerTrack" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getTidalURLFromDeezerTrack" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAmazonURLFromDeezerTrack" -> {
|
||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Log methods
|
||||
"getLogs" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -438,6 +504,14 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"invokeExtensionAction" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val actionName = call.argument<String>("action") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchTracksWithExtensions" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 20
|
||||
@@ -453,6 +527,14 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"enrichTrackWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val trackJson = call.argument<String>("track") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"removeExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -663,6 +745,21 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension Home Feed (Explore)
|
||||
"getExtensionHomeFeed" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionHomeFeedJSON(extensionId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getExtensionBrowseCategories" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 81 KiB |
@@ -42,17 +42,27 @@ class FFmpegServiceIOS {
|
||||
}
|
||||
|
||||
/// Convert FLAC to MP3
|
||||
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
// Convert in same folder, just change extension
|
||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
||||
|
||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) return outputPath;
|
||||
if (result.success) {
|
||||
// Delete original FLAC if requested
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
@@ -177,6 +187,123 @@ class FFmpegServiceIOS {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover art to MP3 file using ID3v2 tags
|
||||
/// Returns the file path on success, null on failure
|
||||
static Future<String?> embedMetadataToMp3({
|
||||
required String mp3Path,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempOutput = '$mp3Path.tmp';
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$mp3Path" ');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v:0 copy ');
|
||||
cmdBuffer.write('-id3v2_version 3 ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
|
||||
final id3Metadata = _convertToId3Tags(metadata);
|
||||
id3Metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg MP3 embed command: $command');
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(mp3Path).delete();
|
||||
await File(tempOutput).rename(mp3Path);
|
||||
_log.d('MP3 metadata embedded successfully');
|
||||
return mp3Path;
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
|
||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final value = entry.value;
|
||||
|
||||
// Map Vorbis comments to ID3v2 frame names
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
id3Map['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
id3Map['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
id3Map['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
id3Map['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
id3Map['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
id3Map['date'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
id3Map['TSRC'] = value; // ID3v2 ISRC frame
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
id3Map['lyrics'] = value;
|
||||
break;
|
||||
default:
|
||||
// Pass through other tags as-is
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return id3Map;
|
||||
}
|
||||
|
||||
/// Check if FFmpeg is available
|
||||
static Future<bool> isAvailable() async {
|
||||
try {
|
||||
|
||||
@@ -17,13 +17,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string // us, eu regions for DoubleDouble service
|
||||
lastAPICallTime time.Time // Rate limiting: track last API call
|
||||
apiCallCount int // Rate limiting: counter per minute
|
||||
apiCallResetTime time.Time // Rate limiting: reset time
|
||||
regions []string
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -38,7 +37,6 @@ type DoubleDoubleSubmitResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
|
||||
type DoubleDoubleStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
FriendlyStatus string `json:"friendlyStatus"`
|
||||
@@ -49,7 +47,6 @@ type DoubleDoubleStatusResponse struct {
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
// amazonArtistsMatch checks if the artist names are similar enough
|
||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
@@ -90,7 +87,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
@@ -100,7 +96,6 @@ func amazonIsASCIIString(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
@@ -113,7 +108,6 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
}
|
||||
|
||||
// waitForRateLimit implements rate limiting similar to PC version
|
||||
// Max 9 requests per minute with 7 second delay between requests
|
||||
func (a *AmazonDownloader) waitForRateLimit() {
|
||||
amazonRateLimitMu.Lock()
|
||||
defer amazonRateLimitMu.Unlock()
|
||||
@@ -125,7 +119,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
a.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit (9 requests per minute), wait until next minute
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
@@ -136,7 +129,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds like PC version)
|
||||
if !a.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
@@ -151,7 +143,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
a.apiCallCount++
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||
// Uses same service as PC version (doubledouble.top)
|
||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||
// DoubleDouble service regions (same as PC)
|
||||
@@ -176,11 +167,9 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
// Step 1: Submit download request with rate limiting
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
// Apply rate limiting before request (like PC version)
|
||||
a.waitForRateLimit()
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
@@ -334,7 +323,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -434,7 +422,6 @@ type AmazonDownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
@@ -564,6 +551,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre, // From Deezer album metadata
|
||||
Label: req.Label, // From Deezer album metadata
|
||||
Copyright: req.Copyright, // From Deezer album metadata
|
||||
}
|
||||
|
||||
// Use cover data from parallel fetch
|
||||
@@ -577,13 +567,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed" // default
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, 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" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
||||
|
||||
@@ -8,18 +8,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||
// Same logic as PC version for consistency
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||
// This avoids file permission issues on Android
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
if coverURL == "" {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
@@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades cover URL to maximum quality
|
||||
// Supports both Spotify and Deezer CDNs
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
@@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
|
||||
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
|
||||
// Available sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
func upgradeDeezerCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return coverURL
|
||||
@@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return upgraded
|
||||
}
|
||||
|
||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
|
||||
@@ -25,13 +25,12 @@ const (
|
||||
deezerMaxParallelISRC = 10
|
||||
)
|
||||
|
||||
// DeezerClient handles Deezer API interactions (no auth required)
|
||||
type DeezerClient struct {
|
||||
httpClient *http.Client
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
artistCache map[string]*cacheEntry
|
||||
isrcCache map[string]string // trackID -> ISRC cache
|
||||
isrcCache map[string]string
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -40,7 +39,6 @@ var (
|
||||
deezerClientOnce sync.Once
|
||||
)
|
||||
|
||||
// GetDeezerClient returns singleton Deezer client
|
||||
func GetDeezerClient() *DeezerClient {
|
||||
deezerClientOnce.Do(func() {
|
||||
deezerClient = &DeezerClient{
|
||||
@@ -54,7 +52,6 @@ func GetDeezerClient() *DeezerClient {
|
||||
return deezerClient
|
||||
}
|
||||
|
||||
// Deezer API response types
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -63,7 +60,7 @@ type deezerTrack struct {
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
@@ -86,8 +83,8 @@ type deezerAlbumSimple struct {
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
ReleaseDate string `json:"release_date"`
|
||||
RecordType string `json:"record_type"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
@@ -132,16 +129,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
type deezerGenre struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type deezerAlbumFull struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"`
|
||||
Label string `json:"label"`
|
||||
Genres struct {
|
||||
Data []deezerGenre `json:"data"`
|
||||
} `json:"genres"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
Tracks struct {
|
||||
@@ -176,7 +182,6 @@ type deezerPlaylistFull struct {
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
// SearchAll searches for tracks and artists on Deezer
|
||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||
@@ -192,8 +197,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0),
|
||||
Artists: make([]SearchArtistResult, 0),
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
}
|
||||
|
||||
// Search tracks - NO ISRC fetch for performance
|
||||
@@ -221,11 +226,9 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||
|
||||
for _, track := range trackResp.Data {
|
||||
// Convert directly without fetching ISRC - much faster
|
||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||
}
|
||||
|
||||
// Search artists
|
||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||
|
||||
@@ -258,7 +261,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||
|
||||
// Cache result
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
@@ -283,7 +285,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAlbum fetches album with tracks
|
||||
// ISRC is fetched in parallel for better performance
|
||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||
c.cacheMu.RLock()
|
||||
@@ -310,15 +311,26 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
artistName = strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// Extract genres as comma-separated string
|
||||
var genres []string
|
||||
for _, g := range album.Genres.Data {
|
||||
if g.Name != "" {
|
||||
genres = append(genres, g.Name)
|
||||
}
|
||||
}
|
||||
genreStr := strings.Join(genres, ", ")
|
||||
|
||||
info := AlbumInfoMetadata{
|
||||
TotalTracks: album.NbTracks,
|
||||
Name: album.Title,
|
||||
ReleaseDate: album.ReleaseDate,
|
||||
Artists: artistName,
|
||||
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||
Images: albumImage,
|
||||
Genre: genreStr, // From Deezer album
|
||||
Label: album.Label, // From Deezer album
|
||||
}
|
||||
|
||||
// Fetch ISRCs in parallel
|
||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||
@@ -328,10 +340,16 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
albumType = "compilation"
|
||||
}
|
||||
|
||||
for _, track := range album.Tracks.Data {
|
||||
for i, track := range album.Tracks.Data {
|
||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||
isrc := isrcMap[trackIDStr]
|
||||
|
||||
// Use track position from API, fallback to index+1 if not provided
|
||||
trackNum := track.TrackPosition
|
||||
if trackNum == 0 {
|
||||
trackNum = i + 1
|
||||
}
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||
Artists: track.Artist.Name,
|
||||
@@ -341,7 +359,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
DurationMS: track.Duration * 1000,
|
||||
Images: albumImage,
|
||||
ReleaseDate: album.ReleaseDate,
|
||||
TrackNumber: track.TrackPosition,
|
||||
TrackNumber: trackNum,
|
||||
TotalTracks: album.NbTracks,
|
||||
DiscNumber: track.DiskNumber,
|
||||
ExternalURL: track.Link,
|
||||
@@ -366,7 +384,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetArtist fetches artist with albums
|
||||
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||
@@ -452,8 +469,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPlaylist fetches playlist with tracks
|
||||
// ISRC is fetched in parallel for better performance
|
||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||
|
||||
@@ -476,7 +491,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
info.Owner.Name = playlist.Title
|
||||
info.Owner.Images = playlistImage
|
||||
|
||||
// Fetch ISRCs in parallel
|
||||
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||
@@ -515,15 +529,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchByISRC searches for a track by ISRC using direct endpoint
|
||||
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
// Use direct ISRC endpoint (API 2.0)
|
||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||
|
||||
var track deezerTrack
|
||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||
// Fallback to search if direct endpoint fails
|
||||
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
||||
var resp struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
@@ -557,13 +567,24 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
||||
|
||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||
result := make(map[string]string)
|
||||
result := make(map[string]string, len(tracks))
|
||||
var resultMu sync.Mutex
|
||||
|
||||
var tracksToFetch []deezerTrack
|
||||
var directISRCs map[string]string
|
||||
c.cacheMu.RLock()
|
||||
for _, track := range tracks {
|
||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||
if track.ISRC != "" {
|
||||
result[trackIDStr] = track.ISRC
|
||||
if _, ok := c.isrcCache[trackIDStr]; !ok {
|
||||
if directISRCs == nil {
|
||||
directISRCs = make(map[string]string)
|
||||
}
|
||||
directISRCs[trackIDStr] = track.ISRC
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
||||
result[trackIDStr] = isrc
|
||||
} else {
|
||||
@@ -571,6 +592,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
}
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
if len(directISRCs) > 0 {
|
||||
c.cacheMu.Lock()
|
||||
for trackIDStr, isrc := range directISRCs {
|
||||
c.isrcCache[trackIDStr] = isrc
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
if len(tracksToFetch) == 0 {
|
||||
return result
|
||||
@@ -585,7 +613,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
go func(t deezerTrack) {
|
||||
defer wg.Done()
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
@@ -614,7 +641,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
return result
|
||||
}
|
||||
|
||||
// GetTrackISRC fetches ISRC for a single track (with caching)
|
||||
// Use this when you need ISRC for download
|
||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||
c.cacheMu.RLock()
|
||||
@@ -624,13 +650,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackID] = fullTrack.ISRC
|
||||
c.cacheMu.Unlock()
|
||||
@@ -677,6 +701,104 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
return album.Cover
|
||||
}
|
||||
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string // Comma-separated list of genres
|
||||
Label string // Record label name
|
||||
}
|
||||
|
||||
// Uses the album ID from a track to fetch extended metadata
|
||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||
if albumID == "" {
|
||||
return nil, fmt.Errorf("empty album ID")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
return entry.data.(*AlbumExtendedMetadata), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||
|
||||
var album deezerAlbumFull
|
||||
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch album: %w", err)
|
||||
}
|
||||
|
||||
var genres []string
|
||||
for _, g := range album.Genres.Data {
|
||||
if g.Name != "" {
|
||||
genres = append(genres, g.Name)
|
||||
}
|
||||
}
|
||||
|
||||
result := &AlbumExtendedMetadata{
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTrackAlbumID fetches the album ID for a Deezer track
|
||||
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||
|
||||
var track deezerTrack
|
||||
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||
}
|
||||
|
||||
// This is a convenience function that first gets the album ID, then fetches album metadata
|
||||
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
||||
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get album ID: %w", err)
|
||||
}
|
||||
|
||||
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
||||
}
|
||||
|
||||
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
|
||||
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
if isrc == "" {
|
||||
return nil, fmt.Errorf("empty ISRC")
|
||||
}
|
||||
|
||||
// First, search for track by ISRC
|
||||
track, err := c.SearchByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
||||
}
|
||||
|
||||
// SpotifyID contains "deezer:123" format, extract the ID
|
||||
deezerID := track.SpotifyID
|
||||
if strings.HasPrefix(deezerID, "deezer:") {
|
||||
deezerID = strings.TrimPrefix(deezerID, "deezer:")
|
||||
}
|
||||
|
||||
if deezerID == "" {
|
||||
return nil, fmt.Errorf("track found but no Deezer ID")
|
||||
}
|
||||
|
||||
// Then fetch extended metadata using the Deezer track ID
|
||||
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||
}
|
||||
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -721,7 +843,6 @@ func parseDeezerURL(input string) (string, string, error) {
|
||||
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
|
||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||
parts = parts[1:]
|
||||
}
|
||||
|
||||
@@ -158,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Use index for fast lookup
|
||||
idx := GetISRCIndex(outputDir)
|
||||
filePath, exists := idx.lookup(isrc)
|
||||
if !exists {
|
||||
@@ -175,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
}
|
||||
|
||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||
// Returns the filepath if exists, empty string if not
|
||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||
return filepath, nil
|
||||
@@ -199,9 +197,6 @@ type FileExistenceResult struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||
// Same implementation as PC version for consistency
|
||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
@@ -266,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
||||
}
|
||||
|
||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||
// This avoids rebuilding the entire index
|
||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ParseSpotifyURL parses and validates a Spotify URL
|
||||
// Returns JSON with type (track/album/playlist) and ID
|
||||
func ParseSpotifyURL(url string) (string, error) {
|
||||
parsed, err := parseSpotifyURI(url)
|
||||
if err != nil {
|
||||
@@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||
SetSpotifyCredentials(clientID, clientSecret)
|
||||
}
|
||||
|
||||
// CheckSpotifyCredentials checks if Spotify credentials are configured
|
||||
// Returns true if credentials are available (custom or env vars)
|
||||
func CheckSpotifyCredentials() bool {
|
||||
return HasSpotifyCredentials()
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||
// Returns JSON with track/album/playlist data
|
||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchSpotify searches for tracks on Spotify
|
||||
// Returns JSON array of track results
|
||||
func SearchSpotify(query string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchSpotifyAll searches for tracks and artists on Spotify
|
||||
// Returns JSON with tracks and artists arrays
|
||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckAvailability checks track availability on streaming services
|
||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
@@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadRequest represents a download request from Flutter
|
||||
type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
@@ -143,48 +129,51 @@ type DownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||
Quality string `json:"quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
||||
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
ItemID string `json:"item_id"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Source string `json:"source"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResponse represents the result of a download
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
// Actual quality info from the source
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata)
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResult is a generic result type for all downloaders
|
||||
type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
@@ -198,9 +187,6 @@ type DownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// DownloadTrack downloads a track from the specified service
|
||||
// requestJSON is a JSON string of DownloadRequest
|
||||
// Returns JSON string of DownloadResponse
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
@@ -214,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
// Add output directory to allowed download dirs for extensions
|
||||
if req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
@@ -338,22 +323,18 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadWithFallback tries to download from services in order
|
||||
// Starts with the preferred service from request, then tries others
|
||||
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields to prevent filename/path issues
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
// Add output directory to allowed download dirs for extensions
|
||||
if req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
@@ -510,47 +491,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
||||
}
|
||||
|
||||
// GetDownloadProgress returns current download progress
|
||||
func GetDownloadProgress() string {
|
||||
progress := getProgress()
|
||||
jsonBytes, _ := json.Marshal(progress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
|
||||
func GetAllDownloadProgress() string {
|
||||
return GetMultiProgress()
|
||||
}
|
||||
|
||||
// InitItemProgress initializes progress tracking for a download item
|
||||
func InitItemProgress(itemID string) {
|
||||
StartItemProgress(itemID)
|
||||
}
|
||||
|
||||
// FinishItemProgress marks a download item as complete and removes tracking
|
||||
func FinishItemProgress(itemID string) {
|
||||
CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
// ClearItemProgress removes progress tracking for a specific item
|
||||
func ClearItemProgress(itemID string) {
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
// CancelDownload cancels an in-progress download for the given item.
|
||||
func CancelDownload(itemID string) {
|
||||
cancelDownload(itemID)
|
||||
}
|
||||
|
||||
// CleanupConnections closes idle HTTP connections
|
||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||
func CleanupConnections() {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
// ReadFileMetadata reads metadata directly from a FLAC file
|
||||
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
|
||||
// This is useful for displaying accurate metadata in the UI without relying on cached data
|
||||
func ReadFileMetadata(filePath string) (string, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
@@ -590,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetDownloadDirectory sets the default download directory
|
||||
func SetDownloadDirectory(path string) error {
|
||||
return setDownloadDir(path)
|
||||
}
|
||||
|
||||
// CheckDuplicate checks if a file with the given ISRC exists
|
||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
||||
|
||||
@@ -612,26 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
|
||||
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
|
||||
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
|
||||
// Returns JSON array of results
|
||||
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
||||
return CheckFilesExistParallel(outputDir, tracksJSON)
|
||||
}
|
||||
|
||||
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
|
||||
// Call this when entering album/playlist screen for faster duplicate checking
|
||||
func PreBuildDuplicateIndex(outputDir string) error {
|
||||
return PreBuildISRCIndex(outputDir)
|
||||
}
|
||||
|
||||
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
||||
func InvalidateDuplicateIndex(outputDir string) {
|
||||
InvalidateISRCCache(outputDir)
|
||||
}
|
||||
|
||||
// BuildFilename builds a filename from template and metadata
|
||||
func BuildFilename(template string, metadataJSON string) (string, error) {
|
||||
var metadata map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||
@@ -642,14 +602,10 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// SanitizeFilename removes invalid characters from filename
|
||||
func SanitizeFilename(filename string) string {
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
|
||||
// FetchLyrics fetches lyrics for a track from LRCLIB
|
||||
// Returns JSON with lyrics data
|
||||
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
@@ -659,10 +615,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"source": lyrics.Source,
|
||||
"sync_type": lyrics.SyncType,
|
||||
"lines": lyrics.Lines,
|
||||
"success": true,
|
||||
"source": lyrics.Source,
|
||||
"sync_type": lyrics.SyncType,
|
||||
"lines": lyrics.Lines,
|
||||
"instrumental": lyrics.Instrumental,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
@@ -673,15 +630,16 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
||||
// First tries to extract from file, then falls back to fetching from internet
|
||||
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
// If filePath is provided, ONLY check file - don't fallback to online
|
||||
// This allows Flutter to distinguish between "from file" vs "from online"
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
// File has no lyrics - return empty, let Flutter call again without filePath
|
||||
return "", nil
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
@@ -691,11 +649,15 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Return special marker for instrumental tracks
|
||||
if lyricsData.Instrumental {
|
||||
return "[instrumental:true]", nil
|
||||
}
|
||||
|
||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
// EmbedLyricsToFile embeds lyrics into an existing FLAC file
|
||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
err := EmbedLyrics(filePath, lyrics)
|
||||
if err != nil {
|
||||
@@ -711,9 +673,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
|
||||
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
|
||||
// This runs in background and returns immediately
|
||||
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
@@ -749,20 +708,14 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetTrackCacheSize returns the current track ID cache size
|
||||
func GetTrackCacheSize() int {
|
||||
return GetCacheSize()
|
||||
}
|
||||
|
||||
// ClearTrackIDCache clears the track ID cache
|
||||
func ClearTrackIDCache() {
|
||||
ClearTrackCache()
|
||||
}
|
||||
|
||||
// ==================== DEEZER API ====================
|
||||
|
||||
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
|
||||
// Returns JSON with tracks and artists arrays
|
||||
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -837,6 +790,37 @@ func ParseDeezerURLExport(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
|
||||
// trackID: Deezer track ID (will look up album ID from track)
|
||||
// Returns JSON with genre, label fields
|
||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
|
||||
if err != nil {
|
||||
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -949,10 +933,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
||||
}
|
||||
|
||||
// ==================== SONGLINK DEEZER SUPPORT ====================
|
||||
|
||||
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
|
||||
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
|
||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
@@ -1136,14 +1116,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Initialize with saved settings
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
// Return extension info as JSON
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"display_name": ext.Manifest.DisplayName,
|
||||
@@ -1290,7 +1268,22 @@ func CleanupExtensions() {
|
||||
manager.UnloadAllExtensions()
|
||||
}
|
||||
|
||||
// ==================== EXTENSION AUTH API ====================
|
||||
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
|
||||
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
|
||||
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
result, err := manager.InvokeAction(extensionID, actionName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
@@ -1371,9 +1364,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION FFMPEG API ====================
|
||||
|
||||
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
|
||||
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
||||
cmd := GetPendingFFmpegCommand(commandID)
|
||||
if cmd == nil {
|
||||
@@ -1433,7 +1423,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
// Extension not found, return original track
|
||||
return trackJSON, nil
|
||||
}
|
||||
|
||||
@@ -1537,10 +1526,6 @@ func GetSearchProvidersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION URL HANDLER ====================
|
||||
|
||||
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
|
||||
// Returns JSON with type, tracks, album info, etc.
|
||||
func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
resultWithID, err := manager.HandleURLWithExtension(url)
|
||||
@@ -1723,6 +1708,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
// Use track number from extension, fallback to index+1 if not provided
|
||||
trackNum := track.TrackNumber
|
||||
if trackNum == 0 {
|
||||
trackNum = i + 1
|
||||
}
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
@@ -1732,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"track_number": trackNum,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
@@ -1745,6 +1735,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"artist_id": album.ArtistID,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
@@ -1802,7 +1793,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
// Parse into album metadata (same structure)
|
||||
var album ExtAlbumMetadata
|
||||
if err := json.Unmarshal(jsonBytes, &album); err != nil {
|
||||
return "", fmt.Errorf("failed to parse playlist: %w", err)
|
||||
@@ -1903,7 +1893,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
|
||||
// Add listeners if present
|
||||
if artist.Listeners > 0 {
|
||||
response["listeners"] = artist.Listeners
|
||||
}
|
||||
@@ -1961,9 +1950,6 @@ func GetURLHandlersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
// RunPostProcessingJSON runs post-processing hooks on a file
|
||||
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
||||
var metadata map[string]interface{}
|
||||
if metadataJSON != "" {
|
||||
@@ -2019,8 +2005,6 @@ func GetPostProcessingProvidersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION STORE ====================
|
||||
|
||||
// InitExtensionStoreJSON initializes the extension store with cache directory
|
||||
func InitExtensionStoreJSON(cacheDir string) error {
|
||||
InitExtensionStore(cacheDir)
|
||||
@@ -2034,7 +2018,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
// Force refresh if requested
|
||||
if forceRefresh {
|
||||
store.FetchRegistry(true)
|
||||
}
|
||||
@@ -2115,3 +2098,53 @@ func ClearStoreCacheJSON() error {
|
||||
store.ClearCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
return extension.%s();
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return "", fmt.Errorf("%s returned null", functionName)
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
|
||||
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
||||
}
|
||||
|
||||
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
|
||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension management functionality
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -15,8 +14,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// compareVersions compares two semantic version strings
|
||||
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||
func compareVersions(v1, v2 string) int {
|
||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||
@@ -46,11 +43,11 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// LoadedExtension represents an extension that has been loaded into memory
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
@@ -71,7 +68,6 @@ var (
|
||||
globalExtManagerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetExtensionManager returns the global extension manager instance
|
||||
func GetExtensionManager() *ExtensionManager {
|
||||
globalExtManagerOnce.Do(func() {
|
||||
globalExtManager = &ExtensionManager{
|
||||
@@ -81,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
|
||||
return globalExtManager
|
||||
}
|
||||
|
||||
// SetDirectories sets the extensions and data directories
|
||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -99,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
|
||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -180,14 +173,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract all files (preserving directory structure)
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve relative path within the zip (support subdirectories)
|
||||
// Clean the path to prevent path traversal attacks
|
||||
relPath := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||
@@ -245,7 +235,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// initializeVM creates and initializes the Goja VM for an extension
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
@@ -322,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExtension returns a loaded extension by ID
|
||||
// Returns error if extension not found (gomobile compatible)
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
@@ -347,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionEnabled enables or disables an extension
|
||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -408,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
return loaded, errors
|
||||
}
|
||||
|
||||
// loadExtensionFromDirectory loads an extension from an already extracted directory
|
||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -497,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpgradeExtension upgrades an existing extension from a new package file
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||
// Validate file extension
|
||||
@@ -644,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// ExtensionUpgradeInfo holds information about extension upgrade check
|
||||
type ExtensionUpgradeInfo struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
@@ -716,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
|
||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||
if err != nil {
|
||||
@@ -736,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
extensions := m.GetAllExtensions()
|
||||
|
||||
type ExtensionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
Types []ExtensionType `json:"types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error_message,omitempty"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
Types []ExtensionType `json:"types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error_message,omitempty"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
infos := make([]ExtensionInfo, len(extensions))
|
||||
@@ -813,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
PostProcessing: ext.Manifest.PostProcessing,
|
||||
Capabilities: ext.Manifest.Capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,7 +811,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
|
||||
// ==================== Extension Lifecycle ====================
|
||||
|
||||
// InitializeExtension calls the extension's initialize method with settings
|
||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -888,7 +872,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExtension calls the extension's cleanup method
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -899,10 +882,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil // No VM, nothing to cleanup
|
||||
return nil
|
||||
}
|
||||
|
||||
// Call cleanup function
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
@@ -951,11 +933,65 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range extensionIDs {
|
||||
// Call cleanup first
|
||||
m.CleanupExtension(id)
|
||||
// Then unload
|
||||
m.UnloadExtension(id)
|
||||
}
|
||||
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
// The function is called as extension.<actionName>() and can return a result
|
||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil, fmt.Errorf("extension VM not initialized")
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
return nil, fmt.Errorf("extension is disabled")
|
||||
}
|
||||
|
||||
// Call the action function on the extension object
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
try {
|
||||
var result = extension.%s();
|
||||
if (result && typeof result.then === 'function') {
|
||||
// Handle promise - return pending status
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
return { success: true, result: result };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'Action function not found: %s' };
|
||||
})()
|
||||
`, actionName, actionName, actionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||
return nil, fmt.Errorf("action failed: %v", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) {
|
||||
return map[string]interface{}{"success": true}, nil
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{"success": true, "result": exported}, nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
SettingTypeNumber SettingType = "number"
|
||||
SettingTypeBool SettingType = "boolean"
|
||||
SettingTypeSelect SettingType = "select"
|
||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||
)
|
||||
|
||||
// ExtensionPermissions defines what resources an extension can access
|
||||
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"` // For select type
|
||||
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
||||
}
|
||||
|
||||
// QualityOption represents a quality option for download providers
|
||||
@@ -105,24 +107,25 @@ type PostProcessingConfig struct {
|
||||
|
||||
// ExtensionManifest represents the manifest.json of an extension
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
}
|
||||
|
||||
// ManifestValidationError represents a validation error in the manifest
|
||||
@@ -149,9 +152,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// Validate checks if the manifest has all required fields and valid values
|
||||
func (m *ExtensionManifest) Validate() error {
|
||||
// Check required fields
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
@@ -172,7 +173,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||
}
|
||||
|
||||
// Validate extension types
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||
return &ManifestValidationError{
|
||||
@@ -198,20 +198,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate setting type
|
||||
validTypes := map[SettingType]bool{
|
||||
SettingTypeString: true,
|
||||
SettingTypeNumber: true,
|
||||
SettingTypeBool: true,
|
||||
SettingTypeSelect: true,
|
||||
}
|
||||
if !validTypes[setting.Type] {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].type", i),
|
||||
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
@@ -219,6 +205,13 @@ func (m *ExtensionManifest) Validate() error {
|
||||
Message: "select type requires options",
|
||||
}
|
||||
}
|
||||
|
||||
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].action", i),
|
||||
Message: "button type requires action (JS function name)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -289,7 +282,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse URL to get host
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -38,6 +39,10 @@ type ExtTrackMetadata struct {
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
|
||||
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
|
||||
Label string `json:"label,omitempty"` // Record label
|
||||
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||
Genre string `json:"genre,omitempty"` // Music genre(s)
|
||||
}
|
||||
|
||||
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
||||
@@ -53,6 +58,7 @@ type ExtAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
@@ -144,6 +150,10 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Call extension's searchTracks function
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
@@ -206,6 +216,10 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') {
|
||||
@@ -252,6 +266,10 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
|
||||
@@ -301,6 +319,10 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') {
|
||||
@@ -349,6 +371,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
return track, nil // Extension disabled, return as-is
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Convert track to JSON for passing to JS
|
||||
trackJSON, err := json.Marshal(track)
|
||||
if err != nil {
|
||||
@@ -415,6 +441,10 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
||||
@@ -460,6 +490,10 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
|
||||
@@ -508,6 +542,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Set up progress callback in VM
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
@@ -758,6 +796,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if enrichedTrack.Artists != "" {
|
||||
req.ArtistName = enrichedTrack.Artists
|
||||
}
|
||||
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
|
||||
if enrichedTrack.Label != "" && req.Label == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||
req.Label = enrichedTrack.Label
|
||||
}
|
||||
if enrichedTrack.Copyright != "" && req.Copyright == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright)
|
||||
req.Copyright = enrichedTrack.Copyright
|
||||
}
|
||||
if enrichedTrack.Genre != "" && req.Genre == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre)
|
||||
req.Genre = enrichedTrack.Genre
|
||||
}
|
||||
if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
|
||||
req.ReleaseDate = enrichedTrack.ReleaseDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -795,6 +850,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: req.Source,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
// Embed genre and label if provided (from Deezer metadata)
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
} else {
|
||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment, copy metadata
|
||||
@@ -878,10 +945,44 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerID) {
|
||||
// For built-in providers, enrich with Deezer metadata if not already present
|
||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
deezerClient := GetDeezerClient()
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
if err == nil && extMeta != nil {
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||
}
|
||||
} else if err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Use built-in provider
|
||||
result, err := tryBuiltInProvider(providerID, req)
|
||||
if err == nil && result.Success {
|
||||
result.Service = providerID
|
||||
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
|
||||
if req.Label != "" {
|
||||
result.Label = req.Label
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
result.Copyright = req.Copyright
|
||||
}
|
||||
if req.Genre != "" {
|
||||
result.Genre = req.Genre
|
||||
}
|
||||
if req.ReleaseDate != "" && result.ReleaseDate == "" {
|
||||
result.ReleaseDate = req.ReleaseDate
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -935,6 +1036,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: providerID,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
// Embed genre and label if provided (from Deezer metadata)
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
} else {
|
||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment and returned metadata, use it
|
||||
@@ -1085,6 +1198,9 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1120,6 +1236,10 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Convert options to JSON
|
||||
optionsJSON, _ := json.Marshal(options)
|
||||
|
||||
@@ -1191,6 +1311,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
|
||||
@@ -1272,6 +1396,10 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||
candidatesJSON, _ := json.Marshal(candidates)
|
||||
|
||||
@@ -1335,6 +1463,10 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension runtime with sandboxed execution
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -17,7 +16,6 @@ var (
|
||||
extensionAuthStateMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ExtensionAuthState holds auth state for an extension
|
||||
type ExtensionAuthState struct {
|
||||
PendingAuthURL string
|
||||
AuthCode string
|
||||
@@ -30,7 +28,6 @@ type ExtensionAuthState struct {
|
||||
PKCEChallenge string
|
||||
}
|
||||
|
||||
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
|
||||
type PendingAuthRequest struct {
|
||||
ExtensionID string
|
||||
AuthURL string
|
||||
@@ -55,7 +52,6 @@ func ClearPendingAuthRequest(extensionID string) {
|
||||
delete(pendingAuthRequests, extensionID)
|
||||
}
|
||||
|
||||
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
|
||||
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
@@ -68,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
state.AuthCode = authCode
|
||||
}
|
||||
|
||||
// SetExtensionTokens sets access/refresh tokens for an extension
|
||||
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
@@ -84,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
// ExtensionRuntime provides sandboxed APIs for extensions
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
@@ -95,7 +89,6 @@ type ExtensionRuntime struct {
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// NewExtensionRuntime creates a new runtime for an extension
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
@@ -108,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
vm: ext.VM,
|
||||
}
|
||||
|
||||
// Create HTTP client with redirect validation to prevent SSRF via open redirect
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
@@ -119,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
}
|
||||
// Also block redirects to private/local networks (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
@@ -136,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
return runtime
|
||||
}
|
||||
|
||||
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
|
||||
type RedirectBlockedError struct {
|
||||
Domain string
|
||||
IsPrivate bool
|
||||
@@ -162,10 +152,10 @@ func isPrivateIP(host string) bool {
|
||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.",
|
||||
"169.254.", // Link-local
|
||||
"::1", // IPv6 localhost
|
||||
"fc00:", // IPv6 private
|
||||
"fe80:", // IPv6 link-local
|
||||
"169.254.",
|
||||
"::1",
|
||||
"fc00:",
|
||||
"fe80:",
|
||||
}
|
||||
|
||||
hostLower := host
|
||||
@@ -183,7 +173,6 @@ func isPrivateIP(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// simpleCookieJar is a simple in-memory cookie jar
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
@@ -208,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
// SetSettings updates the runtime settings
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
@@ -228,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
// Storage API
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
@@ -243,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
|
||||
// Auth API (for OAuth and other auth flows)
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
@@ -270,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
// FFmpeg API (for post-processing)
|
||||
ffmpegObj := vm.NewObject()
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
@@ -284,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||
vm.Set("matching", matchingObj)
|
||||
|
||||
// Utilities
|
||||
utilsObj := vm.NewObject()
|
||||
utilsObj.Set("base64Encode", r.base64Encode)
|
||||
utilsObj.Set("base64Decode", r.base64Decode)
|
||||
@@ -299,6 +283,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
// Log object (already set in extension_manager.go, but we can enhance it)
|
||||
@@ -309,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
logObj.Set("error", r.logError)
|
||||
vm.Set("log", logObj)
|
||||
|
||||
// Go backend functions
|
||||
gobackendObj := vm.NewObject()
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
@@ -320,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
// Global atob/btoa - Base64 encoding (browser-compatible)
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
vm.Set("btoa", r.btoaPolyfill)
|
||||
|
||||
// TextEncoder/TextDecoder constructors
|
||||
r.registerTextEncoderDecoder(vm)
|
||||
|
||||
// URL class for URL parsing
|
||||
r.registerURLClass(vm)
|
||||
|
||||
// JSON global (browser-compatible)
|
||||
r.registerJSONGlobal(vm)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
// authOpenUrl requests Flutter to open an OAuth URL
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
// Store pending auth request for Flutter to pick up
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
// Update auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PendingAuthURL = authURL
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
@@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authGetCode gets the auth code (set by Flutter after OAuth callback)
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authClear clears all auth state for the extension
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
@@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
// authGetTokens returns current tokens (for extension to use in API calls)
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
length = 128
|
||||
}
|
||||
|
||||
// Generate random bytes
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use base64url encoding without padding (RFC 7636 compliant)
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Trim to exact length
|
||||
if len(verifier) > length {
|
||||
verifier = verifier[:length]
|
||||
}
|
||||
@@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// generatePKCEChallenge generates a code challenge from verifier using S256 method
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// authGeneratePKCE generates a PKCE code verifier and challenge pair
|
||||
// Returns: { verifier: string, challenge: string, method: "S256" }
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
// Default length is 64 characters
|
||||
length := 64
|
||||
@@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store in auth state for later use
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier
|
||||
extensionAuthStateMu.RLock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
var verifier string
|
||||
@@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Build token request body
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
@@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// Make token request
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Extract tokens
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
@@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Store tokens in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists = extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
if expiresIn > 0 {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
// Clear PKCE after successful exchange
|
||||
state.PKCEVerifier = ""
|
||||
state.PKCEChallenge = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
// Return full token response
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
@@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
if expiresIn > 0 {
|
||||
result["expires_in"] = expiresIn
|
||||
}
|
||||
// Include any additional fields from response
|
||||
if scope, ok := tokenResp["scope"].(string); ok {
|
||||
result["scope"] = scope
|
||||
}
|
||||
|
||||
@@ -31,14 +31,12 @@ var (
|
||||
ffmpegCommandID int64
|
||||
)
|
||||
|
||||
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
|
||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
return ffmpegCommands[commandID]
|
||||
}
|
||||
|
||||
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
|
||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
@@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
|
||||
}
|
||||
}
|
||||
|
||||
// ClearFFmpegCommand removes a completed FFmpeg command
|
||||
func ClearFFmpegCommand(commandID string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
// ffmpegExecute queues an FFmpeg command for execution by Flutter
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpegGetInfo gets audio file information using FFprobe
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ffmpegConvert is a helper for common conversion operations
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -21,8 +21,6 @@ var (
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
|
||||
// This should be called by the Go backend when setting up download paths
|
||||
func SetAllowedDownloadDirs(dirs []string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
@@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) {
|
||||
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||
}
|
||||
|
||||
// AddAllowedDownloadDir adds a directory to the allowed list
|
||||
func AddAllowedDownloadDir(dir string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
@@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
|
||||
func isPathInAllowedDirs(absPath string) bool {
|
||||
allowedDownloadDirsMu.RLock()
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
@@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
|
||||
// Clean and resolve the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// SECURITY: Block absolute paths by default
|
||||
// Only allow if path is in explicitly allowed download directories
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Check if path is in allowed download directories
|
||||
if isPathInAllowedDirs(absPath) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// Block all other absolute paths
|
||||
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||
}
|
||||
|
||||
// For relative paths, join with data directory (extension's sandbox)
|
||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Ensure path is within data directory (prevent path traversal)
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !strings.HasPrefix(absPath, absDataDir) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
@@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// fileDownload downloads a file from URL to the specified path
|
||||
// Supports progress callback via options.onProgress
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
urlStr := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Validate output path (allows absolute paths for download queue)
|
||||
fullPath, err := r.validatePath(outputPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get options if provided
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Extract headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
headers = make(map[string]string)
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
// Extract onProgress callback
|
||||
if progressVal, ok := opts["onProgress"]; ok {
|
||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||
onProgress = callable
|
||||
@@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
@@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
// Download file
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create output file
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get content length for progress
|
||||
contentLength := resp.ContentLength
|
||||
|
||||
// Copy content with progress reporting
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024) // 32KB buffer
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
@@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if onProgress != nil && contentLength > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||
}
|
||||
@@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists in the sandbox
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
// fileDelete deletes a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileRead reads a file from the sandbox
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileWrite writes data to a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileCopy copies a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Read source file
|
||||
data, err := os.ReadFile(fullSrc)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Write to destination
|
||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileMove moves/renames a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileGetSize returns the size of a file in bytes
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
@@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
// Usage: http.request(url, options) where options = { method, body, headers }
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
@@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
@@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
// httpClearCookies clears all cookies for this extension
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
|
||||
@@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string {
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
// Try to read existing salt
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
if err == nil && len(salt) == 32 {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
// Generate new random salt (32 bytes)
|
||||
salt = make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
// Save salt to file
|
||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||
}
|
||||
@@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -94,7 +95,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get key - can be string or array of bytes
|
||||
var keyBytes []byte
|
||||
keyArg := call.Arguments[0].Export()
|
||||
switch k := keyArg.(type) {
|
||||
@@ -113,7 +113,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get message - can be string or array of bytes
|
||||
var msgBytes []byte
|
||||
msgArg := call.Arguments[1].Export()
|
||||
switch m := msgArg.(type) {
|
||||
@@ -136,7 +135,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
// Convert to array of numbers for JavaScript
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
@@ -268,6 +266,11 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
||||
})
|
||||
}
|
||||
|
||||
// randomUserAgent returns a random Chrome User-Agent string
|
||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
// ==================== Logging Functions ====================
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
@@ -369,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||
})
|
||||
|
||||
// Expose getLocalTime - returns device local time info
|
||||
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||
now := time.Now()
|
||||
_, offsetSeconds := now.Zone()
|
||||
offsetMinutes := offsetSeconds / 60
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"year": now.Year(),
|
||||
"month": int(now.Month()),
|
||||
"day": now.Day(),
|
||||
"hour": now.Hour(),
|
||||
"minute": now.Minute(),
|
||||
"second": now.Second(),
|
||||
"weekday": int(now.Weekday()),
|
||||
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
|
||||
"timezone": now.Location().String(),
|
||||
"timestamp": now.Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
// Load all existing settings
|
||||
return s.loadAllSettings()
|
||||
}
|
||||
|
||||
@@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(settingsPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
|
||||
|
||||
s.settings[extensionID][key] = value
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
@@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
|
||||
delete(s.settings, extensionID)
|
||||
|
||||
// Remove settings file
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
|
||||
@@ -35,7 +35,6 @@ type StoreExtension struct {
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
// Alternative camelCase fields (for flexibility)
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
@@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Invalid filename characters for Android/Windows/Linux
|
||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
|
||||
@@ -30,7 +28,6 @@ func sanitizeFilename(filename string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// buildFilenameFromTemplate builds a filename from template and metadata
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
@@ -91,7 +88,6 @@ func formatDiscNumber(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
|
||||
func extractYear(date string) string {
|
||||
if len(date) >= 4 {
|
||||
return date[:4]
|
||||
|
||||
@@ -15,61 +15,23 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTP utility functions for consistent request handling across all downloaders
|
||||
|
||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||
// Uses modern Chrome format with build and patch numbers
|
||||
// Windows 11 still reports as "Windows NT 10.0" for compatibility
|
||||
func getRandomUserAgent() string {
|
||||
winMajor := rand.Intn(2) + 10
|
||||
|
||||
chromeVersion := rand.Intn(25) + 100
|
||||
chromeBuild := rand.Intn(1500) + 3000
|
||||
chromePatch := rand.Intn(65) + 60
|
||||
// Chrome version 120-145 (modern range)
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
chromePatch := rand.Intn(200) + 100
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||
winMajor,
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||
chromeVersion,
|
||||
chromeBuild,
|
||||
chromePatch,
|
||||
)
|
||||
}
|
||||
|
||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||
// func getRandomMacUserAgent() string {
|
||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||
// webkitMajor := rand.Intn(7) + 530
|
||||
// webkitMinor := rand.Intn(7) + 30
|
||||
// chromeMajor := rand.Intn(25) + 80
|
||||
// chromeBuild := rand.Intn(1500) + 3000
|
||||
// chromePatch := rand.Intn(65) + 60
|
||||
// safariMajor := rand.Intn(7) + 530
|
||||
// safariMinor := rand.Intn(6) + 30
|
||||
//
|
||||
// return fmt.Sprintf(
|
||||
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
// macMajor,
|
||||
// macMinor,
|
||||
// webkitMajor,
|
||||
// webkitMinor,
|
||||
// chromeMajor,
|
||||
// chromeBuild,
|
||||
// chromePatch,
|
||||
// safariMajor,
|
||||
// safariMinor,
|
||||
// )
|
||||
// }
|
||||
|
||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||
// func getRandomDesktopUserAgent() string {
|
||||
// if rand.Intn(2) == 0 {
|
||||
// return getRandomUserAgent() // Windows
|
||||
// }
|
||||
// return getRandomMacUserAgent() // Mac
|
||||
// }
|
||||
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DownloadTimeout = 120 * time.Second
|
||||
@@ -107,7 +69,6 @@ var downloadClient = &http.Client{
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: sharedTransport,
|
||||
@@ -128,7 +89,6 @@ func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||
// Also checks for ISP blocking on errors
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
@@ -147,7 +107,6 @@ type RetryConfig struct {
|
||||
BackoffFactor float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: DefaultMaxRetries,
|
||||
@@ -253,13 +212,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// calculateNextDelay calculates the next delay with exponential backoff
|
||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
@@ -302,7 +259,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ValidateResponse checks if response is valid (non-nil, status 2xx)
|
||||
func ValidateResponse(resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("response is nil")
|
||||
@@ -331,7 +287,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
||||
return msg
|
||||
}
|
||||
|
||||
// ISPBlockingError represents an error caused by ISP blocking
|
||||
type ISPBlockingError struct {
|
||||
Domain string
|
||||
Reason string
|
||||
@@ -447,7 +402,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
||||
// Returns true if ISP blocking was detected
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
ispErr := IsISPBlocking(err, requestURL)
|
||||
@@ -485,7 +439,6 @@ func extractDomain(rawURL string) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
||||
// If ISP blocking is detected, returns a more descriptive error
|
||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a single log entry
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
@@ -16,7 +15,6 @@ type LogEntry struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
||||
type LogBuffer struct {
|
||||
entries []LogEntry
|
||||
maxSize int
|
||||
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
@@ -55,7 +52,6 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
return lb.loggingEnabled
|
||||
}
|
||||
|
||||
// Add adds a log entry to the buffer
|
||||
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,13 +15,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Lyrics Cache with TTL
|
||||
// ========================================
|
||||
|
||||
const (
|
||||
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
|
||||
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
|
||||
lyricsCacheTTL = 24 * time.Hour
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
type lyricsCacheEntry struct {
|
||||
@@ -37,10 +35,8 @@ var globalLyricsCache = &lyricsCache{
|
||||
}
|
||||
|
||||
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||
// Normalize key: lowercase, trim spaces
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||
// Round duration to nearest 10 seconds for cache key
|
||||
roundedDuration := math.Round(durationSec/10) * 10
|
||||
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||
}
|
||||
@@ -55,7 +51,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -74,7 +69,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L
|
||||
}
|
||||
}
|
||||
|
||||
// CleanExpired removes expired entries from cache
|
||||
func (c *lyricsCache) CleanExpired() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -90,7 +84,6 @@ func (c *lyricsCache) CleanExpired() int {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Size returns current cache size
|
||||
func (c *lyricsCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -130,9 +123,7 @@ type LyricsClient struct {
|
||||
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +163,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
return c.parseLRCLibResponse(&lrcResp), nil
|
||||
}
|
||||
|
||||
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
|
||||
// durationSec: track duration in seconds, use 0 to skip duration matching
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||
baseURL := "https://lrclib.net/api/search"
|
||||
params := url.Values{}
|
||||
@@ -206,13 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return nil, fmt.Errorf("no lyrics found")
|
||||
}
|
||||
|
||||
// Filter and score results based on duration matching and synced lyrics
|
||||
bestMatch := c.findBestMatch(results, durationSec)
|
||||
if bestMatch != nil {
|
||||
return c.parseLRCLibResponse(bestMatch), nil
|
||||
}
|
||||
|
||||
// Fallback: return first result with synced lyrics
|
||||
for _, result := range results {
|
||||
if result.SyncedLyrics != "" {
|
||||
return c.parseLRCLibResponse(&result), nil
|
||||
@@ -222,7 +209,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return c.parseLRCLibResponse(&results[0]), nil
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching lyrics based on duration and sync status
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
@@ -230,11 +216,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
|
||||
// Check duration match if target duration is provided
|
||||
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||
|
||||
if durationMatches {
|
||||
// Prefer synced lyrics over plain
|
||||
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = result
|
||||
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||
@@ -243,23 +227,23 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
||||
}
|
||||
}
|
||||
|
||||
// Return synced first, then plain
|
||||
if bestSynced != nil {
|
||||
return bestSynced
|
||||
}
|
||||
return bestPlain
|
||||
}
|
||||
|
||||
// durationMatches checks if two durations are within tolerance
|
||||
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||
diff := math.Abs(lrcDuration - targetDuration)
|
||||
return diff <= durationToleranceSec
|
||||
}
|
||||
|
||||
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
|
||||
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
// Check cache first
|
||||
// Normalize artist name - take first artist before comma/semicolon for better matching
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
|
||||
// Check cache first (use original artist name for cache key)
|
||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
@@ -270,29 +254,44 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
// Try exact match first
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
// Helper to check if lyrics result is valid (has lines OR is instrumental)
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||
}
|
||||
|
||||
// Try exact match first with primary artist
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// Try with full artist name if different from primary
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try with simplified track name
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Search with duration matching
|
||||
query := artistName + " " + trackName
|
||||
// Search with duration matching (use primary artist for search)
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
@@ -300,9 +299,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
|
||||
// Search with simplified name and duration matching
|
||||
if simplifiedTrack != trackName {
|
||||
query = artistName + " " + simplifiedTrack
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
@@ -394,7 +393,6 @@ func msToLRCTimestamp(ms int64) string {
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||
// Use convertToLRCWithMetadata for full LRC with headers
|
||||
// Kept for potential future use
|
||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||
@@ -421,8 +419,6 @@ func msToLRCTimestamp(ms int64) string {
|
||||
// return builder.String()
|
||||
// }
|
||||
|
||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||
// Includes [ti:], [ar:], [by:] headers
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
@@ -430,13 +426,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
// Add metadata headers
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
// Add lyrics lines
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
@@ -485,3 +479,40 @@ func simplifyTrackName(name string) string {
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// normalizeArtistName extracts the primary artist from multi-artist strings
|
||||
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
|
||||
// e.g., "Artist1; Artist2" -> "Artist1"
|
||||
func normalizeArtistName(name string) string {
|
||||
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
|
||||
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||
|
||||
result := name
|
||||
for _, sep := range separators {
|
||||
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
|
||||
result = result[:idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||
if lrcContent == "" {
|
||||
return "", fmt.Errorf("empty LRC content")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(audioFilePath)
|
||||
ext := filepath.Ext(audioFilePath)
|
||||
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||
|
||||
lrcFilePath := filepath.Join(dir, baseName+".lrc")
|
||||
|
||||
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
|
||||
return lrcFilePath, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -11,7 +14,6 @@ import (
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
// Metadata represents track metadata for embedding
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
@@ -24,9 +26,11 @@ type Metadata struct {
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
}
|
||||
|
||||
// EmbedMetadata embeds metadata into a FLAC file
|
||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -82,6 +86,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
if metadata.Genre != "" {
|
||||
setComment(cmt, "GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Label != "" {
|
||||
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -123,8 +139,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
|
||||
// This avoids file permission issues on Android by not requiring a temp file
|
||||
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -180,6 +194,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
if metadata.Genre != "" {
|
||||
setComment(cmt, "GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Label != "" {
|
||||
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -310,7 +336,6 @@ func fileExists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
|
||||
func EmbedLyrics(filePath string, lyrics string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -348,6 +373,51 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
if genre == "" && label == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
for idx, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmtIdx = idx
|
||||
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cmt == nil {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
if genre != "" {
|
||||
setComment(cmt, "GENRE", genre)
|
||||
}
|
||||
if label != "" {
|
||||
setComment(cmt, "ORGANIZATION", label)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
} else {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
@@ -377,16 +447,12 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
// AudioQuality represents audio quality info from a FLAC file
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
}
|
||||
|
||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||
// For M4A files, it delegates to GetM4AQuality
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -452,78 +518,170 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
|
||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||
data, err := os.ReadFile(filePath)
|
||||
input, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||
return fmt.Errorf("failed to open M4A file: %w", err)
|
||||
}
|
||||
defer input.Close()
|
||||
|
||||
moovPos := findAtom(data, "moov", 0)
|
||||
if moovPos < 0 {
|
||||
info, err := input.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat M4A file: %w", err)
|
||||
}
|
||||
fileSize := info.Size()
|
||||
|
||||
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find moov atom: %w", err)
|
||||
}
|
||||
if !moovFound {
|
||||
return fmt.Errorf("moov atom not found in M4A file")
|
||||
}
|
||||
|
||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||
moovContentStart := moovHeader.offset + moovHeader.headerSize
|
||||
moovContentSize := moovHeader.size - moovHeader.headerSize
|
||||
|
||||
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to locate udta atom: %w", err)
|
||||
}
|
||||
|
||||
var metaHeader atomHeader
|
||||
metaFound := false
|
||||
if udtaFound {
|
||||
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
|
||||
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
|
||||
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to locate meta atom: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
metaAtom := buildMetaAtom(metadata, coverData)
|
||||
metaSize := int64(len(metaAtom))
|
||||
|
||||
var newData []byte
|
||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||
var delta int64
|
||||
var newUdtaSize int64
|
||||
switch {
|
||||
case udtaFound && metaFound:
|
||||
delta = metaSize - metaHeader.size
|
||||
newUdtaSize = udtaHeader.size + delta
|
||||
case udtaFound && !metaFound:
|
||||
delta = metaSize
|
||||
newUdtaSize = udtaHeader.size + delta
|
||||
case !udtaFound:
|
||||
newUdtaSize = int64(8 + len(metaAtom))
|
||||
delta = newUdtaSize
|
||||
}
|
||||
|
||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
||||
newData = append(newData, data[:metaPos]...)
|
||||
newData = append(newData, metaAtom...)
|
||||
newData = append(newData, data[metaPos+metaSize:]...)
|
||||
} else {
|
||||
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||
newUdtaSize := 8 + len(newUdtaContent)
|
||||
newUdta := make([]byte, 4)
|
||||
newUdta[0] = byte(newUdtaSize >> 24)
|
||||
newUdta[1] = byte(newUdtaSize >> 16)
|
||||
newUdta[2] = byte(newUdtaSize >> 8)
|
||||
newUdta[3] = byte(newUdtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, newUdtaContent...)
|
||||
newMoovSize := moovHeader.size + delta
|
||||
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
|
||||
return fmt.Errorf("moov atom exceeds 32-bit size after update")
|
||||
}
|
||||
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
|
||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
||||
}
|
||||
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
|
||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
||||
}
|
||||
|
||||
newData = append(newData, data[:udtaPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||
tempPath := filePath + ".tmp"
|
||||
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
cleanupTemp := true
|
||||
defer func() {
|
||||
_ = output.Close()
|
||||
if cleanupTemp {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
} else {
|
||||
udtaContent := metaAtom
|
||||
udtaSize := 8 + len(udtaContent)
|
||||
newUdta := make([]byte, 4)
|
||||
newUdta[0] = byte(udtaSize >> 24)
|
||||
newUdta[1] = byte(udtaSize >> 16)
|
||||
newUdta[2] = byte(udtaSize >> 8)
|
||||
newUdta[3] = byte(udtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, udtaContent...)
|
||||
}()
|
||||
|
||||
insertPos := moovPos + moovSize
|
||||
newData = append(newData, data[:insertPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[insertPos:]...)
|
||||
switch {
|
||||
case udtaFound && metaFound:
|
||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(metaAtom); err != nil {
|
||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
||||
}
|
||||
metaEnd := metaHeader.offset + metaHeader.size
|
||||
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
case udtaFound && !metaFound:
|
||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
insertPos := udtaHeader.offset + udtaHeader.size
|
||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(metaAtom); err != nil {
|
||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
||||
}
|
||||
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
|
||||
return err
|
||||
}
|
||||
case !udtaFound:
|
||||
newUdtaAtom := buildUdtaAtom(metaAtom)
|
||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
moovEnd := moovHeader.offset + moovHeader.size
|
||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(newUdtaAtom); err != nil {
|
||||
return fmt.Errorf("failed to write udta atom: %w", err)
|
||||
}
|
||||
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
newMoovSize := moovSize + len(newData) - len(data)
|
||||
newData[moovPos] = byte(newMoovSize >> 24)
|
||||
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||
newData[moovPos+3] = byte(newMoovSize)
|
||||
|
||||
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||
if err := output.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
_ = input.Close()
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("failed to replace original file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, filePath); err != nil {
|
||||
return fmt.Errorf("failed to move temp file: %w", err)
|
||||
}
|
||||
cleanupTemp = false
|
||||
|
||||
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// findAtom finds an atom by name starting from offset
|
||||
func findAtom(data []byte, name string, offset int) int {
|
||||
for i := offset; i < len(data)-8; {
|
||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
||||
@@ -615,7 +773,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
return metaAtom
|
||||
}
|
||||
|
||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||
func buildTextAtom(name, value string) []byte {
|
||||
valueBytes := []byte(value)
|
||||
|
||||
@@ -667,7 +824,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
return atom
|
||||
}
|
||||
|
||||
// buildDiscNumberAtom builds disk atom
|
||||
func buildDiscNumberAtom(disc, total int) []byte {
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 22, // size
|
||||
@@ -693,9 +849,9 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
|
||||
// buildCoverAtom builds covr atom with image data
|
||||
func buildCoverAtom(coverData []byte) []byte {
|
||||
imageType := byte(13) // default JPEG
|
||||
imageType := byte(13)
|
||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||
imageType = 14 // PNG
|
||||
imageType = 14
|
||||
}
|
||||
|
||||
dataSize := 16 + len(coverData)
|
||||
@@ -705,8 +861,8 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
dataAtom[2] = byte(dataSize >> 8)
|
||||
dataAtom[3] = byte(dataSize)
|
||||
dataAtom = append(dataAtom, []byte("data")...)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, 0, 0, 0, imageType)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0)
|
||||
dataAtom = append(dataAtom, coverData...)
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
@@ -721,30 +877,226 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
return atom
|
||||
}
|
||||
|
||||
// GetM4AQuality reads audio quality from M4A file
|
||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
moovPos := findAtom(data, "moov", 0)
|
||||
if moovPos < 0 {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
|
||||
}
|
||||
fileSize := info.Size()
|
||||
|
||||
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
|
||||
}
|
||||
if !moovFound {
|
||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||
}
|
||||
|
||||
for i := moovPos; i < len(data)-20; i++ {
|
||||
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||
if i+24 < len(data) {
|
||||
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||
bitDepth := 16
|
||||
if string(data[i:i+4]) == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
}
|
||||
}
|
||||
moovStart := moovHeader.offset
|
||||
moovEnd := moovHeader.offset + moovHeader.size
|
||||
|
||||
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
|
||||
if err != nil {
|
||||
return AudioQuality{}, err
|
||||
}
|
||||
|
||||
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
||||
buf := make([]byte, 24)
|
||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||
}
|
||||
|
||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
}
|
||||
|
||||
type atomHeader struct {
|
||||
offset int64
|
||||
size int64
|
||||
headerSize int64
|
||||
typ string
|
||||
}
|
||||
|
||||
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
|
||||
if offset+8 > fileSize {
|
||||
return atomHeader{}, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
headerBuf := make([]byte, 8)
|
||||
if _, err := f.ReadAt(headerBuf, offset); err != nil {
|
||||
return atomHeader{}, err
|
||||
}
|
||||
|
||||
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
|
||||
typ := string(headerBuf[4:8])
|
||||
|
||||
if size32 == 1 {
|
||||
if offset+16 > fileSize {
|
||||
return atomHeader{}, io.ErrUnexpectedEOF
|
||||
}
|
||||
extBuf := make([]byte, 8)
|
||||
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
|
||||
return atomHeader{}, err
|
||||
}
|
||||
size64 := binary.BigEndian.Uint64(extBuf)
|
||||
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
|
||||
}
|
||||
|
||||
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
|
||||
}
|
||||
|
||||
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
|
||||
if size <= 0 {
|
||||
return atomHeader{}, false, nil
|
||||
}
|
||||
|
||||
end := start + size
|
||||
pos := start
|
||||
|
||||
for pos+8 <= end {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return atomHeader{}, false, err
|
||||
}
|
||||
|
||||
atomSize := header.size
|
||||
if atomSize == 0 {
|
||||
atomSize = end - pos
|
||||
}
|
||||
|
||||
if atomSize < header.headerSize {
|
||||
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
header.size = atomSize
|
||||
if header.typ == target {
|
||||
return header, true, nil
|
||||
}
|
||||
|
||||
pos += atomSize
|
||||
}
|
||||
|
||||
return atomHeader{}, false, nil
|
||||
}
|
||||
|
||||
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
|
||||
if len(typ) != 4 {
|
||||
return fmt.Errorf("invalid atom type: %s", typ)
|
||||
}
|
||||
|
||||
if headerSize == 16 {
|
||||
header := make([]byte, 16)
|
||||
binary.BigEndian.PutUint32(header[0:4], 1)
|
||||
copy(header[4:8], []byte(typ))
|
||||
binary.BigEndian.PutUint64(header[8:16], uint64(size))
|
||||
_, err := w.Write(header)
|
||||
return err
|
||||
}
|
||||
|
||||
if size > int64(^uint32(0)) {
|
||||
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
|
||||
}
|
||||
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
||||
copy(header[4:8], []byte(typ))
|
||||
_, err := w.Write(header)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
|
||||
if length <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := src.Seek(offset, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek source: %w", err)
|
||||
}
|
||||
if _, err := io.CopyN(dst, src, length); err != nil {
|
||||
return fmt.Errorf("failed to copy data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUdtaAtom(metaAtom []byte) []byte {
|
||||
size := 8 + len(metaAtom)
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
||||
copy(header[4:8], []byte("udta"))
|
||||
return append(header, metaAtom...)
|
||||
}
|
||||
|
||||
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||
const chunkSize = 64 * 1024
|
||||
patternMP4A := []byte("mp4a")
|
||||
patternALAC := []byte("alac")
|
||||
|
||||
var tail []byte
|
||||
readPos := start
|
||||
|
||||
for readPos < end {
|
||||
toRead := end - readPos
|
||||
if toRead > chunkSize {
|
||||
toRead = chunkSize
|
||||
}
|
||||
|
||||
buf := make([]byte, toRead)
|
||||
n, err := f.ReadAt(buf, readPos)
|
||||
if err != nil && err != io.EOF {
|
||||
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
data := append(tail, buf[:n]...)
|
||||
mp4aIdx := bytes.Index(data, patternMP4A)
|
||||
alacIdx := bytes.Index(data, patternALAC)
|
||||
|
||||
bestIdx := -1
|
||||
bestType := ""
|
||||
switch {
|
||||
case mp4aIdx >= 0 && alacIdx >= 0:
|
||||
if mp4aIdx <= alacIdx {
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
} else {
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
}
|
||||
case mp4aIdx >= 0:
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
case alacIdx >= 0:
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
}
|
||||
|
||||
if bestIdx >= 0 {
|
||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||
if absolute+24 > fileSize {
|
||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||
}
|
||||
return absolute, bestType, nil
|
||||
}
|
||||
|
||||
if len(data) >= 3 {
|
||||
tail = append([]byte{}, data[len(data)-3:]...)
|
||||
} else {
|
||||
tail = append([]byte{}, data...)
|
||||
}
|
||||
|
||||
readPos += int64(n)
|
||||
}
|
||||
|
||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ISRC to Track ID Cache
|
||||
// ========================================
|
||||
|
||||
// TrackIDCacheEntry holds cached track ID with metadata
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
@@ -18,7 +13,6 @@ type TrackIDCacheEntry struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// TrackIDCache caches ISRC to track ID mappings
|
||||
type TrackIDCache struct {
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
@@ -30,7 +24,6 @@ var (
|
||||
trackIDCacheOnce sync.Once
|
||||
)
|
||||
|
||||
// GetTrackIDCache returns the global track ID cache
|
||||
func GetTrackIDCache() *TrackIDCache {
|
||||
trackIDCacheOnce.Do(func() {
|
||||
globalTrackIDCache = &TrackIDCache{
|
||||
@@ -41,7 +34,6 @@ func GetTrackIDCache() *TrackIDCache {
|
||||
return globalTrackIDCache
|
||||
}
|
||||
|
||||
// Get retrieves a cached entry by ISRC
|
||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -53,7 +45,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
return entry
|
||||
}
|
||||
|
||||
// SetTidal caches Tidal track ID for an ISRC
|
||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -67,7 +58,6 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// SetQobuz caches Qobuz track ID for an ISRC
|
||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -81,7 +71,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// SetAmazon caches Amazon track ID for an ISRC
|
||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -95,24 +84,18 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// Clear removes all cached entries
|
||||
func (c *TrackIDCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||
}
|
||||
|
||||
// Size returns the number of cached entries
|
||||
func (c *TrackIDCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parallel Download Helper
|
||||
// ========================================
|
||||
|
||||
// ParallelDownloadResult holds results from parallel operations
|
||||
type ParallelDownloadResult struct {
|
||||
CoverData []byte
|
||||
@@ -122,9 +105,6 @@ type ParallelDownloadResult struct {
|
||||
LyricsErr error
|
||||
}
|
||||
|
||||
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||
// This runs while the main audio download is happening
|
||||
// durationMs: track duration in milliseconds for lyrics matching
|
||||
func FetchCoverAndLyricsParallel(
|
||||
coverURL string,
|
||||
maxQualityCover bool,
|
||||
@@ -153,7 +133,6 @@ func FetchCoverAndLyricsParallel(
|
||||
}()
|
||||
}
|
||||
|
||||
// Fetch lyrics in parallel
|
||||
if embedLyrics {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -180,11 +159,6 @@ func FetchCoverAndLyricsParallel(
|
||||
return result
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Pre-warm Cache for Album/Playlist
|
||||
// ========================================
|
||||
|
||||
// PreWarmCacheRequest represents a track to pre-warm cache for
|
||||
type PreWarmCacheRequest struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
@@ -193,8 +167,6 @@ type PreWarmCacheRequest struct {
|
||||
Service string // "tidal", "qobuz", "amazon"
|
||||
}
|
||||
|
||||
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
||||
// This runs in background while user is viewing the track list
|
||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
if len(requests) == 0 {
|
||||
return
|
||||
@@ -214,8 +186,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
wg.Add(1)
|
||||
go func(r PreWarmCacheRequest) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{} // Acquire
|
||||
defer func() { <-semaphore }() // Release
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
switch r.Service {
|
||||
case "tidal":
|
||||
@@ -259,12 +231,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exported Functions for Flutter
|
||||
// ========================================
|
||||
|
||||
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
||||
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
|
||||
@@ -272,13 +238,11 @@ func PreWarmCache(tracksJSON string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearTrackCache clears the track ID cache
|
||||
func ClearTrackCache() {
|
||||
GetTrackIDCache().Clear()
|
||||
fmt.Println("[Cache] Track ID cache cleared")
|
||||
}
|
||||
|
||||
// GetCacheSize returns the current cache size
|
||||
func GetCacheSize() int {
|
||||
return GetTrackIDCache().Size()
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadProgress represents current download progress
|
||||
// Now unified - returns data from multi-progress system
|
||||
type DownloadProgress struct {
|
||||
CurrentFile string `json:"current_file"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -15,21 +13,19 @@ type DownloadProgress struct {
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ItemProgress represents progress for a single download item
|
||||
type ItemProgress struct {
|
||||
ItemID string `json:"item_id"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||
Progress float64 `json:"progress"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// MultiProgress holds progress for multiple concurrent downloads
|
||||
type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
@@ -38,12 +34,10 @@ var (
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
// Multi-download progress tracking (unified system)
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getProgress returns current download progress from multi-progress system
|
||||
func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -62,7 +56,6 @@ func getProgress() DownloadProgress {
|
||||
return DownloadProgress{}
|
||||
}
|
||||
|
||||
// GetMultiProgress returns progress for all active downloads as JSON
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -74,7 +67,6 @@ func GetMultiProgress() string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// GetItemProgress returns progress for a specific item as JSON
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -201,14 +193,6 @@ func setDownloadDir(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDownloadDir returns the default download directory
|
||||
// Kept for potential future use
|
||||
// func getDownloadDir() string {
|
||||
// downloadDirMu.RLock()
|
||||
// defer downloadDirMu.RUnlock()
|
||||
// return downloadDir
|
||||
// }
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// QobuzDownloader handles Qobuz downloads
|
||||
type QobuzDownloader struct {
|
||||
client *http.Client
|
||||
appID string
|
||||
@@ -29,7 +28,6 @@ var (
|
||||
qobuzDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
// QobuzTrack represents a Qobuz track
|
||||
type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -50,7 +48,6 @@ type QobuzTrack struct {
|
||||
} `json:"performer"`
|
||||
}
|
||||
|
||||
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
@@ -93,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzSplitArtists splits artist string by common separators
|
||||
func qobuzSplitArtists(artists string) []string {
|
||||
// Replace common separators with a standard one
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
@@ -154,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// qobuzTitlesMatch checks if track titles are similar enough
|
||||
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||
@@ -164,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
|
||||
cleanExpected := qobuzCleanTitle(normExpected)
|
||||
cleanFound := qobuzCleanTitle(normFound)
|
||||
|
||||
@@ -177,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if cleaned versions contain each other
|
||||
if cleanExpected != "" && cleanFound != "" {
|
||||
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Extract core title (before any parentheses/brackets)
|
||||
coreExpected := qobuzExtractCoreTitle(normExpected)
|
||||
coreFound := qobuzExtractCoreTitle(normFound)
|
||||
|
||||
@@ -225,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string {
|
||||
return strings.TrimSpace(title[:cutIdx])
|
||||
}
|
||||
|
||||
// qobuzCleanTitle removes common suffixes from track titles for comparison
|
||||
func qobuzCleanTitle(title string) string {
|
||||
cleaned := title
|
||||
|
||||
// Remove content in parentheses/brackets that are version indicators
|
||||
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
||||
versionPatterns := []string{
|
||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||
"album version", "radio edit", "original mix", "extended",
|
||||
"club mix", "remix", "live", "acoustic", "demo",
|
||||
}
|
||||
|
||||
// Remove parenthetical content if it contains version indicators
|
||||
for {
|
||||
startParen := strings.LastIndex(cleaned, "(")
|
||||
endParen := strings.LastIndex(cleaned, ")")
|
||||
@@ -258,7 +244,6 @@ func qobuzCleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Same for brackets
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
endBracket := strings.LastIndex(cleaned, "]")
|
||||
@@ -279,7 +264,6 @@ func qobuzCleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Remove trailing " - version" patterns
|
||||
dashPatterns := []string{
|
||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||
" - live", " - acoustic", " - demo", " - remix",
|
||||
@@ -290,7 +274,6 @@ func qobuzCleanTitle(title string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
for strings.Contains(cleaned, " ") {
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||
}
|
||||
@@ -350,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
qobuzDownloaderOnce.Do(func() {
|
||||
globalQobuzDownloader = &QobuzDownloader{
|
||||
@@ -361,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
return globalQobuzDownloader
|
||||
}
|
||||
|
||||
// GetTrackByID fetches track info directly by Qobuz track ID
|
||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
// Qobuz API: /track/get?track_id=XXX
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||
@@ -412,7 +393,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return apis
|
||||
}
|
||||
|
||||
// SearchTrackByISRC searches for a track by ISRC
|
||||
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||
@@ -455,7 +435,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
@@ -500,7 +479,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||
|
||||
if len(isrcMatches) > 0 {
|
||||
// Verify duration if provided
|
||||
if expectedDurationSec > 0 {
|
||||
var durationVerifiedMatches []*QobuzTrack
|
||||
for _, track := range isrcMatches {
|
||||
@@ -508,7 +486,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
// Allow 10 seconds tolerance
|
||||
if durationDiff <= 10 {
|
||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||
}
|
||||
@@ -520,14 +497,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
|
||||
// ISRC matches but duration doesn't
|
||||
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||
expectedDurationSec, isrcMatches[0].Duration)
|
||||
}
|
||||
|
||||
// No duration to verify, return first match
|
||||
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
return isrcMatches[0], nil
|
||||
}
|
||||
@@ -539,17 +514,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
||||
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||
}
|
||||
|
||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||
}
|
||||
|
||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||
// Also includes title verification to prevent wrong song downloads
|
||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
@@ -688,7 +660,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
|
||||
if len(durationMatches) > 0 {
|
||||
// Return best quality among duration matches
|
||||
for _, track := range durationMatches {
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||
@@ -701,7 +672,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
return durationMatches[0], nil
|
||||
}
|
||||
|
||||
// No duration match found
|
||||
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||
}
|
||||
|
||||
@@ -731,8 +701,6 @@ type qobuzAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
@@ -748,9 +716,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
|
||||
@@ -839,8 +805,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
apis := q.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
@@ -938,7 +902,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// QobuzDownloadResult contains download result with quality info
|
||||
type QobuzDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
@@ -952,7 +915,6 @@ type QobuzDownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// downloadFromQobuz downloads a track using the request parameters
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
@@ -1110,16 +1072,25 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
albumName = req.AlbumName
|
||||
}
|
||||
|
||||
// Use track number from request if available, otherwise from Qobuz API
|
||||
actualTrackNumber := req.TrackNumber
|
||||
if actualTrackNumber == 0 {
|
||||
actualTrackNumber = track.TrackNumber
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: track.Title,
|
||||
Artist: track.Performer.Name,
|
||||
Album: albumName,
|
||||
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||
Date: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre, // From Deezer album metadata
|
||||
Label: req.Label, // From Deezer album metadata
|
||||
Copyright: req.Copyright, // From Deezer album metadata
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
@@ -1132,13 +1103,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
@@ -1155,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
Artist: track.Performer.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||
ISRC: track.ISRC,
|
||||
}, nil
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter implements a sliding window rate limiter
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxRequests int
|
||||
@@ -13,7 +12,6 @@ type RateLimiter struct {
|
||||
timestamps []time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter with specified max requests per window
|
||||
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
maxRequests: maxRequests,
|
||||
@@ -22,8 +20,6 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSlot blocks until a request is allowed under the rate limit
|
||||
// Returns immediately if under the limit, otherwise waits until a slot is available
|
||||
func (r *RateLimiter) WaitForSlot() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -70,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// TryAcquire attempts to acquire a slot without blocking
|
||||
// Returns true if successful, false if rate limit would be exceeded
|
||||
func (r *RateLimiter) TryAcquire() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -87,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Available returns the number of requests available in the current window
|
||||
func (r *RateLimiter) Available() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -99,7 +92,6 @@ func (r *RateLimiter) Available() int {
|
||||
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
|
||||
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
|
||||
|
||||
// GetSongLinkRateLimiter returns the global SongLink rate limiter
|
||||
func GetSongLinkRateLimiter() *RateLimiter {
|
||||
return songLinkRateLimiter
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Hiragana to Romaji mapping
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
// Katakana to Romaji mapping
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
// Combination mappings for きゃ, しゃ, etc.
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
|
||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||
}
|
||||
|
||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||
func JapaneseToRomaji(text string) string {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// BuildSearchQuery creates a search query from track name and artist
|
||||
// Converts Japanese to romaji if present
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
// Convert Japanese to romaji
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
// cleanSearchQuery removes special characters that might interfere with search
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||
// This is useful for creating search queries that work better with Tidal's search
|
||||
func CleanToASCII(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
|
||||
@@ -11,12 +11,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SongLinkClient handles song.link API interactions
|
||||
type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// TrackAvailability represents track availability on different platforms
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
@@ -35,7 +33,6 @@ var (
|
||||
songLinkClientOnce sync.Once
|
||||
)
|
||||
|
||||
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
songLinkClientOnce.Do(func() {
|
||||
globalSongLinkClient = &SongLinkClient{
|
||||
@@ -45,7 +42,6 @@ func NewSongLinkClient() *SongLinkClient {
|
||||
return globalSongLinkClient
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks track availability on streaming platforms
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("spotify track ID is empty")
|
||||
@@ -126,7 +122,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetStreamingURLs gets streaming URLs for a Spotify track
|
||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -191,7 +186,6 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -213,7 +207,6 @@ type AlbumAvailability struct {
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
}
|
||||
|
||||
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
// Use global rate limiter
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
@@ -283,11 +276,6 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Deezer ID Support - Query SongLink using Deezer as source
|
||||
// ========================================
|
||||
|
||||
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
||||
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
if deezerTrackID == "" {
|
||||
@@ -374,7 +362,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||
// entityType: "song" or "album"
|
||||
// entityID: the ID on that platform
|
||||
@@ -472,7 +459,6 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
@@ -500,7 +486,6 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
|
||||
return availability.TidalURL, nil
|
||||
}
|
||||
|
||||
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
|
||||
@@ -24,7 +24,6 @@ const (
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
albumCacheTTL = 10 * time.Minute
|
||||
@@ -32,7 +31,6 @@ const (
|
||||
|
||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||
|
||||
// cacheEntry holds cached data with expiration
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
|
||||
return time.Now().After(e.expiresAt)
|
||||
}
|
||||
|
||||
// SpotifyMetadataClient handles Spotify API interactions
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
clientID string
|
||||
clientSecret string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||
tokenMu sync.Mutex
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
albumCache map[string]*cacheEntry // key: albumID
|
||||
artistCache map[string]*cacheEntry
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// Custom credentials storage (set from Flutter)
|
||||
var (
|
||||
customClientID string
|
||||
customClientSecret string
|
||||
@@ -79,7 +74,6 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
customClientSecret = clientSecret
|
||||
}
|
||||
|
||||
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||
func HasSpotifyCredentials() bool {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
@@ -114,8 +108,6 @@ func getCredentials() (string, string, error) {
|
||||
return "", "", ErrNoSpotifyCredentials
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
// Returns error if credentials are not configured
|
||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
clientID, clientSecret, err := getCredentials()
|
||||
if err != nil {
|
||||
@@ -137,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TrackMetadata represents track information
|
||||
type TrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
@@ -155,7 +146,6 @@ type TrackMetadata struct {
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
}
|
||||
|
||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
||||
type AlbumTrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
@@ -172,25 +162,26 @@ type AlbumTrackMetadata struct {
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumInfoMetadata holds album information
|
||||
type AlbumInfoMetadata struct {
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistId string `json:"artist_id,omitempty"`
|
||||
Images string `json:"images"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumResponsePayload is the response for album requests
|
||||
type AlbumResponsePayload struct {
|
||||
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
// PlaylistInfoMetadata holds playlist information
|
||||
type PlaylistInfoMetadata struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
@@ -202,13 +193,11 @@ type PlaylistInfoMetadata struct {
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
// PlaylistResponsePayload is the response for playlist requests
|
||||
type PlaylistResponsePayload struct {
|
||||
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
// ArtistInfoMetadata holds artist information
|
||||
type ArtistInfoMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -217,7 +206,6 @@ type ArtistInfoMetadata struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
// ArtistAlbumMetadata holds album info for artist discography
|
||||
type ArtistAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -228,24 +216,20 @@ type ArtistAlbumMetadata struct {
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
// ArtistResponsePayload is the response for artist requests
|
||||
type ArtistResponsePayload struct {
|
||||
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||
}
|
||||
|
||||
// TrackResponse is the response for single track requests
|
||||
type TrackResponse struct {
|
||||
Track TrackMetadata `json:"track"`
|
||||
}
|
||||
|
||||
// SearchResult represents search results
|
||||
type SearchResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SearchArtistResult represents an artist in search results
|
||||
type SearchArtistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -254,7 +238,6 @@ type SearchArtistResult struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
// SearchAllResult represents combined search results for tracks and artists
|
||||
type SearchAllResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
@@ -271,7 +254,6 @@ type accessTokenResponse struct {
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// Internal API response types
|
||||
type image struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -297,7 +279,7 @@ type albumSimplified struct {
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type trackFull struct {
|
||||
@@ -312,7 +294,6 @@ type trackFull struct {
|
||||
Artists []artist `json:"artists"`
|
||||
}
|
||||
|
||||
// GetFilteredData fetches and formats Spotify data
|
||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
parsed, err := parseSpotifyURI(spotifyURL)
|
||||
if err != nil {
|
||||
@@ -338,7 +319,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTracks searches for tracks on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
@@ -385,7 +365,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchAll searches for tracks and artists on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
@@ -507,7 +486,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Track item structure for pagination
|
||||
type trackItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -535,19 +513,25 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
}
|
||||
|
||||
albumImage := firstImageURL(data.Images)
|
||||
|
||||
// Get first artist ID
|
||||
var firstArtistId string
|
||||
if len(data.Artists) > 0 {
|
||||
firstArtistId = data.Artists[0].ID
|
||||
}
|
||||
|
||||
info := AlbumInfoMetadata{
|
||||
TotalTracks: data.TotalTracks,
|
||||
Name: data.Name,
|
||||
ReleaseDate: data.ReleaseDate,
|
||||
Artists: joinArtists(data.Artists),
|
||||
ArtistId: firstArtistId,
|
||||
Images: albumImage,
|
||||
}
|
||||
|
||||
// Collect all tracks (including paginated)
|
||||
allTrackItems := data.Tracks.Items
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
// Fetch remaining tracks using pagination (no limit)
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []trackItem `json:"items"`
|
||||
@@ -569,7 +553,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
trackIDs[i] = item.ID
|
||||
}
|
||||
|
||||
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||
@@ -609,10 +592,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||
// Similar to Deezer implementation for consistency
|
||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
const maxParallelISRC = 10
|
||||
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
@@ -621,7 +602,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
return result
|
||||
}
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, maxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -630,7 +610,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
@@ -651,7 +630,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||
// First request to get playlist info and first batch of tracks
|
||||
var data struct {
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
@@ -677,10 +655,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
info.Owner.Name = data.Name
|
||||
info.Owner.Images = firstImageURL(data.Images)
|
||||
|
||||
// Pre-allocate with expected capacity
|
||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||
|
||||
// Add first batch of tracks
|
||||
for _, item := range data.Tracks.Items {
|
||||
if item.Track == nil {
|
||||
continue
|
||||
@@ -704,7 +680,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
for nextURL != "" {
|
||||
@@ -716,7 +691,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||
// Log error but return what we have so far
|
||||
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||
break
|
||||
}
|
||||
@@ -763,7 +737,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Fetch artist info
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -786,7 +759,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
Popularity: artistData.Popularity,
|
||||
}
|
||||
|
||||
// Fetch artist albums (all types: album, single, compilation)
|
||||
albums := make([]ArtistAlbumMetadata, 0)
|
||||
offset := 0
|
||||
limit := 50
|
||||
@@ -826,13 +798,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
})
|
||||
}
|
||||
|
||||
// Check if there are more albums
|
||||
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||
break
|
||||
}
|
||||
offset += limit
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
if offset > 500 {
|
||||
break
|
||||
}
|
||||
@@ -913,7 +883,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
||||
return err
|
||||
}
|
||||
|
||||
// Set headers (same as PC version baseHeaders)
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
@@ -949,8 +918,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
c.rngMu.Lock()
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
// Use Mac User-Agent format (same as PC version)
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMajor := c.rng.Intn(4) + 11
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
@@ -975,7 +943,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Handle spotify: URI format
|
||||
if strings.HasPrefix(trimmed, "spotify:") {
|
||||
parts := strings.Split(trimmed, ":")
|
||||
if len(parts) == 3 {
|
||||
@@ -986,13 +953,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle URL format
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return spotifyURI{}, err
|
||||
}
|
||||
|
||||
// Handle embed.spotify.com URLs
|
||||
if parsed.Host == "embed.spotify.com" {
|
||||
if parsed.RawQuery == "" {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
@@ -1005,7 +970,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return parseSpotifyURI(embedded)
|
||||
}
|
||||
|
||||
// Handle plain ID (no scheme/host) - defaults to playlist
|
||||
if parsed.Scheme == "" && parsed.Host == "" {
|
||||
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
||||
if id == "" {
|
||||
@@ -1031,7 +995,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Skip intl- prefix if present
|
||||
if strings.HasPrefix(parts[0], "intl-") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
@@ -1039,7 +1002,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
|
||||
if len(parts) == 2 {
|
||||
switch parts[0] {
|
||||
case "album", "track", "playlist", "artist":
|
||||
@@ -1047,7 +1009,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested playlist URLs: /user/{user}/playlist/{id}
|
||||
if len(parts) == 4 && parts[2] == "playlist" {
|
||||
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TidalDownloader handles Tidal downloads
|
||||
type TidalDownloader struct {
|
||||
client *http.Client
|
||||
clientID string
|
||||
@@ -35,7 +34,6 @@ var (
|
||||
tidalDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
// TidalTrack represents a Tidal track
|
||||
type TidalTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -60,7 +58,6 @@ type TidalTrack struct {
|
||||
} `json:"mediaMetadata"`
|
||||
}
|
||||
|
||||
// TidalAPIResponseV2 is the new API response format (version 2.0)
|
||||
type TidalAPIResponseV2 struct {
|
||||
Version string `json:"version"`
|
||||
Data struct {
|
||||
@@ -76,7 +73,6 @@ type TidalAPIResponseV2 struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format
|
||||
type TidalBTSManifest struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Codecs string `json:"codecs"`
|
||||
@@ -84,7 +80,6 @@ type TidalBTSManifest struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
// MPD represents DASH manifest structure
|
||||
type MPD struct {
|
||||
XMLName xml.Name `xml:"MPD"`
|
||||
Period struct {
|
||||
@@ -105,7 +100,6 @@ type MPD struct {
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
|
||||
func NewTidalDownloader() *TidalDownloader {
|
||||
tidalDownloaderOnce.Do(func() {
|
||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||
@@ -150,7 +144,6 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||
return apis
|
||||
}
|
||||
|
||||
// GetAccessToken gets Tidal access token (with caching)
|
||||
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
t.tokenMu.Lock()
|
||||
defer t.tokenMu.Unlock()
|
||||
@@ -199,7 +192,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
@@ -239,7 +231,6 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
|
||||
return tidalLink.URL, nil
|
||||
}
|
||||
|
||||
// GetTrackIDFromURL extracts track ID from Tidal URL
|
||||
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
parts := strings.Split(tidalURL, "/track/")
|
||||
if len(parts) < 2 {
|
||||
@@ -293,7 +284,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||
return &trackInfo, nil
|
||||
}
|
||||
|
||||
// SearchTrackByISRC searches for a track by ISRC
|
||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
if err != nil {
|
||||
@@ -341,30 +331,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
// normalizeTitle normalizes a track title for comparison
|
||||
// Kept for potential future use
|
||||
// func normalizeTitle(title string) string {
|
||||
// normalized := strings.ToLower(strings.TrimSpace(title))
|
||||
//
|
||||
// // Remove common suffixes in parentheses or brackets
|
||||
// suffixPatterns := []string{
|
||||
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||
// }
|
||||
// for _, suffix := range suffixPatterns {
|
||||
// normalized = strings.TrimSuffix(normalized, suffix)
|
||||
// }
|
||||
//
|
||||
// // Remove multiple spaces
|
||||
// for strings.Contains(normalized, " ") {
|
||||
// normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||
// }
|
||||
//
|
||||
// return normalized
|
||||
// }
|
||||
|
||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
@@ -466,7 +432,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
if len(result.Items) > 0 {
|
||||
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||
|
||||
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
|
||||
if spotifyISRC != "" {
|
||||
for i := range result.Items {
|
||||
if result.Items[i].ISRC == spotifyISRC {
|
||||
@@ -592,7 +557,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
return bestMatch, nil
|
||||
}
|
||||
|
||||
// containsQuery checks if a query already exists in the list
|
||||
func containsQuery(queries []string, query string) bool {
|
||||
for _, q := range queries {
|
||||
if q == query {
|
||||
@@ -602,7 +566,6 @@ func containsQuery(queries []string, query string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
|
||||
}
|
||||
@@ -614,7 +577,6 @@ type TidalDownloadInfo struct {
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// tidalAPIResult holds the result from a parallel API request
|
||||
type tidalAPIResult struct {
|
||||
apiURL string
|
||||
info TidalDownloadInfo
|
||||
@@ -622,9 +584,7 @@ type tidalAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
// "Siapa cepat dia dapat" - first success wins
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
@@ -639,9 +599,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
@@ -671,7 +629,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
@@ -715,7 +672,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||
|
||||
// Don't return immediately - drain remaining results to avoid goroutine leaks
|
||||
go func(remaining int) {
|
||||
for j := 0; j < remaining; j++ {
|
||||
<-resultChan
|
||||
@@ -736,8 +692,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||
apis := t.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
@@ -752,7 +706,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
@@ -859,7 +812,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
}
|
||||
|
||||
// Initialize item progress for direct downloads
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -946,14 +898,10 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
|
||||
directURL != "", initURL != "", len(mediaURLs))
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
client := NewHTTPClientWithTimeout(120 * time.Second)
|
||||
|
||||
if directURL != "" {
|
||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1135,7 +1083,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
return nil
|
||||
}
|
||||
|
||||
// TidalDownloadResult contains download result with quality info
|
||||
type TidalDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
@@ -1149,12 +1096,10 @@ type TidalDownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// artistsMatch checks if the artist names are similar enough
|
||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||
|
||||
// Exact match
|
||||
if normSpotify == normTidal {
|
||||
return true
|
||||
}
|
||||
@@ -1164,22 +1109,17 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Split artists by common separators (comma, feat, ft., &, and)
|
||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
||||
spotifyArtists := splitArtists(normSpotify)
|
||||
tidalArtists := splitArtists(normTidal)
|
||||
|
||||
// Check if ANY expected artist matches ANY found artist
|
||||
for _, exp := range spotifyArtists {
|
||||
for _, fnd := range tidalArtists {
|
||||
if exp == fnd {
|
||||
return true
|
||||
}
|
||||
// Also check contains for partial matches
|
||||
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||
return true
|
||||
}
|
||||
// Check same words different order
|
||||
if sameWordsUnordered(exp, fnd) {
|
||||
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||
return true
|
||||
@@ -1187,9 +1127,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||
spotifyLatin := isLatinScript(spotifyArtist)
|
||||
tidalLatin := isLatinScript(tidalArtist)
|
||||
if spotifyLatin != tidalLatin {
|
||||
@@ -1200,9 +1137,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// splitArtists splits artist string by common separators
|
||||
func splitArtists(artists string) []string {
|
||||
// Replace common separators with a standard one
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
@@ -1224,8 +1159,6 @@ func splitArtists(artists string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// sameWordsUnordered checks if two strings have the same words regardless of order
|
||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
||||
func sameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
@@ -1235,13 +1168,11 @@ func sameWordsUnordered(a, b string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort and compare
|
||||
sortedA := make([]string, len(wordsA))
|
||||
sortedB := make([]string, len(wordsB))
|
||||
copy(sortedA, wordsA)
|
||||
copy(sortedB, wordsB)
|
||||
|
||||
// Simple bubble sort (usually just 2-3 words)
|
||||
for i := 0; i < len(sortedA)-1; i++ {
|
||||
for j := i + 1; j < len(sortedA); j++ {
|
||||
if sortedA[i] > sortedA[j] {
|
||||
@@ -1261,7 +1192,6 @@ func sameWordsUnordered(a, b string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// titlesMatch checks if track titles are similar enough
|
||||
func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||
@@ -1271,7 +1201,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
@@ -1284,7 +1213,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if cleaned versions contain each other
|
||||
if cleanExpected != "" && cleanFound != "" {
|
||||
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||
return true
|
||||
@@ -1299,7 +1227,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
expectedLatin := isLatinScript(expectedTitle)
|
||||
foundLatin := isLatinScript(foundTitle)
|
||||
@@ -1311,9 +1238,7 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// extractCoreTitle extracts the main title before any parentheses or brackets
|
||||
func extractCoreTitle(title string) string {
|
||||
// Find first occurrence of ( or [
|
||||
parenIdx := strings.Index(title, "(")
|
||||
bracketIdx := strings.Index(title, "[")
|
||||
dashIdx := strings.Index(title, " - ")
|
||||
@@ -1332,18 +1257,15 @@ func extractCoreTitle(title string) string {
|
||||
return strings.TrimSpace(title[:cutIdx])
|
||||
}
|
||||
|
||||
// cleanTitle removes common suffixes from track titles for comparison
|
||||
func cleanTitle(title string) string {
|
||||
cleaned := title
|
||||
|
||||
// Version indicators to remove from parentheses/brackets
|
||||
versionPatterns := []string{
|
||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||
"album version", "radio edit", "original mix", "extended",
|
||||
"club mix", "remix", "live", "acoustic", "demo",
|
||||
}
|
||||
|
||||
// Remove parenthetical content if it contains version indicators
|
||||
for {
|
||||
startParen := strings.LastIndex(cleaned, "(")
|
||||
endParen := strings.LastIndex(cleaned, ")")
|
||||
@@ -1364,7 +1286,6 @@ func cleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Same for brackets
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
endBracket := strings.LastIndex(cleaned, "]")
|
||||
@@ -1385,7 +1306,6 @@ func cleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Remove trailing " - version" patterns
|
||||
dashPatterns := []string{
|
||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||
" - live", " - acoustic", " - demo", " - remix",
|
||||
@@ -1396,7 +1316,6 @@ func cleanTitle(title string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
for strings.Contains(cleaned, " ") {
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||
}
|
||||
@@ -1404,48 +1323,28 @@ func cleanTitle(title string) string {
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
// isLatinScript checks if a string is primarily Latin script
|
||||
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||
func isLatinScript(s string) bool {
|
||||
for _, r := range s {
|
||||
// Skip common punctuation and numbers
|
||||
if r < 128 {
|
||||
continue
|
||||
}
|
||||
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||
// Latin Extended-B: U+0180 to U+024F
|
||||
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||
if (r >= 0x0100 && r <= 0x024F) ||
|
||||
(r >= 0x1E00 && r <= 0x1EFF) ||
|
||||
(r >= 0x00C0 && r <= 0x00FF) {
|
||||
continue
|
||||
}
|
||||
// CJK ranges - definitely different script
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||
(r >= 0x3040 && r <= 0x309F) ||
|
||||
(r >= 0x30A0 && r <= 0x30FF) ||
|
||||
(r >= 0xAC00 && r <= 0xD7AF) ||
|
||||
(r >= 0x0600 && r <= 0x06FF) ||
|
||||
(r >= 0x0400 && r <= 0x04FF) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isASCIIString checks if a string contains only ASCII characters
|
||||
// Kept for potential future use
|
||||
// func isASCIIString(s string) bool {
|
||||
// for _, r := range s {
|
||||
// if r > 127 {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
// downloadFromTidal downloads a track using the request parameters
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
@@ -1453,16 +1352,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Convert expected duration from ms to seconds
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
|
||||
var track *TidalTrack
|
||||
var err error
|
||||
|
||||
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
|
||||
if req.TidalID != "" {
|
||||
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
|
||||
// Parse track ID (could be a number or extracted from URL)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
@@ -1475,7 +1371,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||
@@ -1487,8 +1382,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC)
|
||||
// Strategy 1: Search by metadata, match by ISRC (most accurate)
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||
@@ -1510,7 +1403,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Try SongLink if we have Spotify ID
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
@@ -1545,13 +1437,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
track = nil
|
||||
}
|
||||
|
||||
// Verify duration if we have expected duration
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
// Allow 3 seconds tolerance (same as PC version)
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
@@ -1563,11 +1453,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
|
||||
if track == nil {
|
||||
GoLog("[Tidal] Trying metadata search as last resort...\n")
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||
// Verify artist AND title for metadata search
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
@@ -1578,7 +1466,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
// Verify title first
|
||||
if !titlesMatch(req.TrackName, track.Title) {
|
||||
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.TrackName, track.Title)
|
||||
@@ -1599,7 +1486,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Final verification logging
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
@@ -1633,7 +1519,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
|
||||
// Clean up any leftover .tmp files from previous failed downloads
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
@@ -1651,10 +1536,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Log actual quality received
|
||||
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -1670,7 +1553,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
GoLog("[Tidal] Starting download to: %s\n", outputPath)
|
||||
GoLog("[Tidal] Download URL type: %s\n", func() string {
|
||||
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||
@@ -1688,7 +1570,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
fmt.Println("[Tidal] Download completed successfully")
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
@@ -1701,21 +1582,38 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
// Neither FLAC nor M4A exists
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
}
|
||||
|
||||
// Embed metadata using parallel-fetched cover data
|
||||
releaseDate := req.ReleaseDate
|
||||
if releaseDate == "" && track.Album.ReleaseDate != "" {
|
||||
releaseDate = track.Album.ReleaseDate
|
||||
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
||||
}
|
||||
|
||||
// Use track number from request if available, otherwise from Tidal API
|
||||
actualTrackNumber := req.TrackNumber
|
||||
actualDiscNumber := req.DiscNumber
|
||||
if actualTrackNumber == 0 {
|
||||
actualTrackNumber = track.TrackNumber
|
||||
}
|
||||
if actualDiscNumber == 0 {
|
||||
actualDiscNumber = track.VolumeNumber
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber, // Use actual track number from Tidal
|
||||
Date: releaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
|
||||
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
||||
DiscNumber: actualDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
@@ -1724,46 +1622,41 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
// Embed metadata based on file type
|
||||
if strings.HasSuffix(actualOutputPath, ".flac") {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Tidal] Lyrics embedded successfully")
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Tidal] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Tidal] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||
// Embed metadata to M4A file
|
||||
// GoLog("[Tidal] Embedding metadata to M4A file...\n")
|
||||
|
||||
// Add lyrics to metadata if available
|
||||
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
// metadata.Lyrics = parallelResult.LyricsLRC
|
||||
// }
|
||||
|
||||
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
|
||||
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
|
||||
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
|
||||
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
|
||||
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
|
||||
// GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
|
||||
// } else {
|
||||
// fmt.Println("[Tidal] M4A metadata embedded successfully")
|
||||
// }
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
|
||||
return TidalDownloadResult{
|
||||
@@ -1774,8 +1667,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
Artist: track.Artist.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
DiscNumber: track.VolumeNumber,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: actualDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkDuplicatesBatch":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let outputDir = args["output_dir"] as! String
|
||||
let tracksJson = args["tracks"] as? String ?? "[]"
|
||||
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "preBuildDuplicateIndex":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let outputDir = args["output_dir"] as! String
|
||||
GobackendPreBuildDuplicateIndex(outputDir, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "invalidateDuplicateIndex":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let outputDir = args["output_dir"] as! String
|
||||
GobackendInvalidateDuplicateIndex(outputDir)
|
||||
return nil
|
||||
|
||||
case "buildFilename":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let template = args["template"] as! String
|
||||
@@ -227,6 +248,13 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerExtendedMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let trackId = args["track_id"] as! String
|
||||
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "convertSpotifyToDeezer":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
@@ -242,6 +270,43 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailabilityFromDeezerID":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailabilityByPlatformID":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let platform = args["platform"] as! String
|
||||
let entityType = args["entity_type"] as! String
|
||||
let entityId = args["entity_id"] as! String
|
||||
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getSpotifyIDFromDeezerTrack":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getTidalURLFromDeezerTrack":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAmazonURLFromDeezerTrack":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "preWarmTrackCache":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let tracksJson = args["tracks"] as! String
|
||||
@@ -375,6 +440,14 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "invokeExtensionAction":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let actionName = args["action"] as! String
|
||||
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTracksWithExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
@@ -389,6 +462,14 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "enrichTrackWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let trackJson = args["track"] as? String ?? "{}"
|
||||
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "removeExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
@@ -590,6 +671,21 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
// Extension Home Feed API
|
||||
case "getExtensionHomeFeed":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getExtensionBrowseCategories":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
@@ -36,7 +36,12 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
locale = Locale(localeString);
|
||||
if (localeString.contains('_')) {
|
||||
final parts = localeString.split('_');
|
||||
locale = Locale(parts[0], parts[1]);
|
||||
} else {
|
||||
locale = Locale(localeString);
|
||||
}
|
||||
}
|
||||
|
||||
return DynamicColorWrapper(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.1.1';
|
||||
static const String buildNumber = '60';
|
||||
static const String version = '3.2.1';
|
||||
static const String buildNumber = '64';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
|
||||
import 'app_localizations_nl.dart';
|
||||
import 'app_localizations_pt.dart';
|
||||
import 'app_localizations_ru.dart';
|
||||
import 'app_localizations_tr.dart';
|
||||
import 'app_localizations_zh.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -107,6 +108,7 @@ abstract class AppLocalizations {
|
||||
Locale('de'),
|
||||
Locale('en'),
|
||||
Locale('es'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('fr'),
|
||||
Locale('hi'),
|
||||
Locale('id'),
|
||||
@@ -114,7 +116,9 @@ abstract class AppLocalizations {
|
||||
Locale('ko'),
|
||||
Locale('nl'),
|
||||
Locale('pt'),
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ru'),
|
||||
Locale('tr'),
|
||||
Locale('zh'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
@@ -276,6 +280,12 @@ abstract class AppLocalizations {
|
||||
/// **'Single track downloads will appear here'**
|
||||
String get historyNoSinglesSubtitle;
|
||||
|
||||
/// Search bar placeholder in history
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search history...'**
|
||||
String get historySearchHint;
|
||||
|
||||
/// Settings screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -816,6 +826,12 @@ abstract class AppLocalizations {
|
||||
/// **'The talented artist who created our beautiful app logo!'**
|
||||
String get aboutLogoArtist;
|
||||
|
||||
/// Section for translators
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translators'**
|
||||
String get aboutTranslators;
|
||||
|
||||
/// Section for special thanks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -864,6 +880,36 @@ abstract class AppLocalizations {
|
||||
/// **'Suggest new features for the app'**
|
||||
String get aboutFeatureRequestSubtitle;
|
||||
|
||||
/// Link to Telegram channel
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Telegram Channel'**
|
||||
String get aboutTelegramChannel;
|
||||
|
||||
/// Subtitle for Telegram channel
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Announcements and updates'**
|
||||
String get aboutTelegramChannelSubtitle;
|
||||
|
||||
/// Link to Telegram chat group
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Telegram Community'**
|
||||
String get aboutTelegramChat;
|
||||
|
||||
/// Subtitle for Telegram chat
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Chat with other users'**
|
||||
String get aboutTelegramChatSubtitle;
|
||||
|
||||
/// Section for social links
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Social'**
|
||||
String get aboutSocial;
|
||||
|
||||
/// Section for support/donation links
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1680,6 +1726,12 @@ abstract class AppLocalizations {
|
||||
/// **'Found {count} tracks in CSV. Add them to download queue?'**
|
||||
String dialogImportPlaylistMessage(int count);
|
||||
|
||||
/// Label shown in quality picker for CSV import
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks from CSV'**
|
||||
String csvImportTracks(int count);
|
||||
|
||||
/// Snackbar - track added to download queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2604,6 +2656,60 @@ abstract class AppLocalizations {
|
||||
/// **'File Settings'**
|
||||
String get sectionFileSettings;
|
||||
|
||||
/// Settings section header
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics'**
|
||||
String get sectionLyrics;
|
||||
|
||||
/// Setting - how to save lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics Mode'**
|
||||
String get lyricsMode;
|
||||
|
||||
/// Lyrics mode picker description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose how lyrics are saved with your downloads'**
|
||||
String get lyricsModeDescription;
|
||||
|
||||
/// Lyrics mode option - embed in audio file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Embed in file'**
|
||||
String get lyricsModeEmbed;
|
||||
|
||||
/// Subtitle for embed option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics stored inside FLAC metadata'**
|
||||
String get lyricsModeEmbedSubtitle;
|
||||
|
||||
/// Lyrics mode option - separate LRC file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'External .lrc file'**
|
||||
String get lyricsModeExternal;
|
||||
|
||||
/// Subtitle for external option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Separate .lrc file for players like Samsung Music'**
|
||||
String get lyricsModeExternalSubtitle;
|
||||
|
||||
/// Lyrics mode option - embed and external
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Both'**
|
||||
String get lyricsModeBoth;
|
||||
|
||||
/// Subtitle for both option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Embed and save .lrc file'**
|
||||
String get lyricsModeBothSubtitle;
|
||||
|
||||
/// Settings section header
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2808,6 +2914,24 @@ abstract class AppLocalizations {
|
||||
/// **'Release date'**
|
||||
String get trackReleaseDate;
|
||||
|
||||
/// Metadata label - music genre
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Genre'**
|
||||
String get trackGenre;
|
||||
|
||||
/// Metadata label - record label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Label'**
|
||||
String get trackLabel;
|
||||
|
||||
/// Metadata label - copyright information
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copyright'**
|
||||
String get trackCopyright;
|
||||
|
||||
/// Metadata label - download date
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2838,6 +2962,24 @@ abstract class AppLocalizations {
|
||||
/// **'Failed to load lyrics'**
|
||||
String get trackLyricsLoadFailed;
|
||||
|
||||
/// Action - embed lyrics into audio file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Embed Lyrics'**
|
||||
String get trackEmbedLyrics;
|
||||
|
||||
/// Snackbar - lyrics saved to file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics embedded successfully'**
|
||||
String get trackLyricsEmbedded;
|
||||
|
||||
/// Message when track is instrumental (no lyrics)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Instrumental track'**
|
||||
String get trackInstrumental;
|
||||
|
||||
/// Snackbar - content copied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3252,6 +3394,36 @@ abstract class AppLocalizations {
|
||||
/// **'24-bit / up to 192kHz'**
|
||||
String get qualityHiResFlacMaxSubtitle;
|
||||
|
||||
/// Quality option - MP3 lossy format
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3'**
|
||||
String get qualityMp3;
|
||||
|
||||
/// Technical spec for MP3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'320kbps (converted from FLAC)'**
|
||||
String get qualityMp3Subtitle;
|
||||
|
||||
/// Setting - enable MP3 quality option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable MP3 Option'**
|
||||
String get enableMp3Option;
|
||||
|
||||
/// Subtitle when MP3 is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 quality option is available'**
|
||||
String get enableMp3OptionSubtitleOn;
|
||||
|
||||
/// Subtitle when MP3 is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloads FLAC then converts to 320kbps MP3'**
|
||||
String get enableMp3OptionSubtitleOff;
|
||||
|
||||
/// Note about quality availability
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3534,6 +3706,18 @@ abstract class AppLocalizations {
|
||||
/// **'Albums/[2005] Album Name/'**
|
||||
String get albumFolderYearAlbumSubtitle;
|
||||
|
||||
/// Album folder option with singles inside artist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist / Album + Singles'**
|
||||
String get albumFolderArtistAlbumSingles;
|
||||
|
||||
/// Folder structure example
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist/Album/ and Artist/Singles/'**
|
||||
String get albumFolderArtistAlbumSinglesSubtitle;
|
||||
|
||||
/// Button - delete selected tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3588,6 +3772,12 @@ abstract class AppLocalizations {
|
||||
/// **'Select tracks to delete'**
|
||||
String get downloadedAlbumSelectToDelete;
|
||||
|
||||
/// Header for disc separator in multi-disc albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disc {discNumber}'**
|
||||
String downloadedAlbumDiscHeader(int discNumber);
|
||||
|
||||
/// Extension capability - utility functions
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3629,6 +3819,108 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Error: {message}'**
|
||||
String errorGeneric(String message);
|
||||
|
||||
/// Button - download artist discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download Discography'**
|
||||
String get discographyDownload;
|
||||
|
||||
/// Option - download entire discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download All'**
|
||||
String get discographyDownloadAll;
|
||||
|
||||
/// Subtitle showing total tracks and albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks from {albumCount} releases'**
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount);
|
||||
|
||||
/// Option - download only albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Albums Only'**
|
||||
String get discographyAlbumsOnly;
|
||||
|
||||
/// Subtitle showing album tracks count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks from {albumCount} albums'**
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount);
|
||||
|
||||
/// Option - download only singles
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Singles & EPs Only'**
|
||||
String get discographySinglesOnly;
|
||||
|
||||
/// Subtitle showing singles tracks count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks from {albumCount} singles'**
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount);
|
||||
|
||||
/// Option - manually select albums to download
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select Albums...'**
|
||||
String get discographySelectAlbums;
|
||||
|
||||
/// Subtitle for select albums option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose specific albums or singles'**
|
||||
String get discographySelectAlbumsSubtitle;
|
||||
|
||||
/// Progress - fetching album tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetching tracks...'**
|
||||
String get discographyFetchingTracks;
|
||||
|
||||
/// Progress - fetching specific album
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetching {current} of {total}...'**
|
||||
String discographyFetchingAlbum(int current, int total);
|
||||
|
||||
/// Selection count badge
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} selected'**
|
||||
String discographySelectedCount(int count);
|
||||
|
||||
/// Button - download selected albums
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download Selected'**
|
||||
String get discographyDownloadSelected;
|
||||
|
||||
/// Snackbar - tracks added from discography
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {count} tracks to queue'**
|
||||
String discographyAddedToQueue(int count);
|
||||
|
||||
/// Snackbar - with skipped tracks count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{added} added, {skipped} already downloaded'**
|
||||
String discographySkippedDownloaded(int added, int skipped);
|
||||
|
||||
/// Error - no albums found for artist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No albums available'**
|
||||
String get discographyNoAlbums;
|
||||
|
||||
/// Error - some albums failed to load
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to fetch some albums'**
|
||||
String get discographyFailedToFetch;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
@@ -3653,6 +3945,7 @@ class _AppLocalizationsDelegate
|
||||
'nl',
|
||||
'pt',
|
||||
'ru',
|
||||
'tr',
|
||||
'zh',
|
||||
].contains(locale.languageCode);
|
||||
|
||||
@@ -3663,6 +3956,22 @@ class _AppLocalizationsDelegate
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
// Lookup logic when language+country codes are specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'es':
|
||||
{
|
||||
switch (locale.countryCode) {
|
||||
case 'ES':
|
||||
return AppLocalizationsEsEs();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pt':
|
||||
{
|
||||
switch (locale.countryCode) {
|
||||
case 'PT':
|
||||
return AppLocalizationsPtPt();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'zh':
|
||||
{
|
||||
switch (locale.countryCode) {
|
||||
@@ -3699,6 +4008,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
return AppLocalizationsPt();
|
||||
case 'ru':
|
||||
return AppLocalizationsRu();
|
||||
case 'tr':
|
||||
return AppLocalizationsTr();
|
||||
case 'zh':
|
||||
return AppLocalizationsZh();
|
||||
}
|
||||
|
||||
@@ -111,11 +111,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Einzelne Titel-Downloads werden hier angezeigt';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Einstellungen';
|
||||
|
||||
@override
|
||||
String get settingsDownload => 'Download';
|
||||
String get settingsDownload => 'Herunterladen';
|
||||
|
||||
@override
|
||||
String get settingsAppearance => 'Erscheinungsbild';
|
||||
@@ -130,7 +133,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsAbout => 'Über';
|
||||
|
||||
@override
|
||||
String get downloadTitle => 'Download';
|
||||
String get downloadTitle => 'Herunterladen';
|
||||
|
||||
@override
|
||||
String get downloadLocation => 'Download-Speicherort';
|
||||
@@ -410,40 +413,61 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Besonderer Dank';
|
||||
|
||||
@override
|
||||
String get aboutLinks => 'Links';
|
||||
|
||||
@override
|
||||
String get aboutMobileSource => 'Mobile source code';
|
||||
String get aboutMobileSource => 'Mobiler Quellcode';
|
||||
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
String get aboutPCSource => 'PC Quellcode';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
String get aboutReportIssue => 'Problem melden';
|
||||
|
||||
@override
|
||||
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
|
||||
String get aboutReportIssueSubtitle =>
|
||||
'Melde jedes Problem, die dir auftreten';
|
||||
|
||||
@override
|
||||
String get aboutFeatureRequest => 'Feature request';
|
||||
String get aboutFeatureRequest => 'Feature vorschlagen';
|
||||
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
String get aboutFeatureRequestSubtitle =>
|
||||
'Schlage neue Funktionen für die App vor';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
String get aboutBuyMeCoffeeSubtitle =>
|
||||
'Unterstütze die Entwicklung auf Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
@@ -453,25 +477,25 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
|
||||
'Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDoubleDesc =>
|
||||
'Amazing API for Amazon Music downloads. Thank you for making it free!';
|
||||
'Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!';
|
||||
|
||||
@override
|
||||
String get aboutDabMusic => 'DAB Music';
|
||||
|
||||
@override
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
@@ -904,6 +928,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1437,6 +1466,35 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1549,6 +1607,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1564,6 +1631,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1792,6 +1868,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1936,6 +2028,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1983,6 +2082,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -2007,4 +2111,70 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -402,6 +405,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -426,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -894,6 +915,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1427,6 +1453,35 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1539,6 +1594,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1554,6 +1618,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1782,6 +1855,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1926,6 +2015,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1973,6 +2069,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -1997,4 +2098,70 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -402,6 +405,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -426,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -894,6 +915,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1427,6 +1453,35 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1539,6 +1594,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1554,6 +1618,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1782,6 +1855,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1926,6 +2015,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1973,6 +2069,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -1997,4 +2098,70 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -402,6 +405,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -426,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -894,6 +915,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1427,6 +1453,35 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1539,6 +1594,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1554,6 +1618,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1782,6 +1855,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1926,6 +2015,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1973,6 +2069,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -1997,4 +2098,70 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Unduhan lagu satuan akan muncul di sini';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Pengaturan';
|
||||
|
||||
@@ -406,6 +409,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Terima Kasih Khusus';
|
||||
|
||||
@@ -431,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get aboutFeatureRequestSubtitle =>
|
||||
'Sarankan fitur baru untuk aplikasi';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Dukungan';
|
||||
|
||||
@@ -900,6 +921,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Menambahkan \"$trackName\" ke antrian';
|
||||
@@ -1437,6 +1463,35 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'Pengaturan File';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Warna';
|
||||
|
||||
@@ -1549,6 +1604,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Tanggal rilis';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Diunduh';
|
||||
|
||||
@@ -1564,6 +1628,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||
|
||||
@@ -1794,6 +1867,22 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Aktifkan Opsi MP3';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Unduh FLAC lalu konversi ke MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||
@@ -1939,6 +2028,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||
|
||||
@@ -1986,6 +2082,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Fungsi Utilitas';
|
||||
|
||||
@@ -2010,4 +2111,70 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Unduh Diskografi';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount rilis';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Album Saja';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount album';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Single & EP Saja';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount single';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Pilih Album...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Pilih album atau single tertentu';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Mengambil lagu...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Mengambil $current dari $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count dipilih';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Unduh yang Dipilih';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Menambahkan $count lagu ke antrian';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added ditambahkan, $skipped sudah diunduh';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'Tidak ada album tersedia';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => '設定';
|
||||
|
||||
@@ -402,6 +405,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'スペシャルサンクス';
|
||||
|
||||
@@ -426,6 +432,21 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -894,6 +915,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1427,6 +1453,35 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1539,6 +1594,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1554,6 +1618,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1782,6 +1855,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1926,6 +2015,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1973,6 +2069,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -1997,4 +2098,70 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -402,6 +405,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -426,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -894,6 +915,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1427,6 +1453,35 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1539,6 +1594,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1554,6 +1618,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1782,6 +1855,22 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1926,6 +2015,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1973,6 +2069,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -1997,4 +2098,70 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -402,6 +405,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -426,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -894,6 +915,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1427,6 +1453,35 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1539,6 +1594,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1554,6 +1618,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1782,6 +1855,22 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1926,6 +2015,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1973,6 +2069,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -1997,4 +2098,70 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -87,9 +87,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count альбомов',
|
||||
one: '1 альбом',
|
||||
many: '$count альбомов',
|
||||
few: '$count альбома',
|
||||
one: '$count альбом',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Здесь будут отображаться загрузки синглов';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Настройки';
|
||||
|
||||
@@ -414,6 +417,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'Талантливый художник, который создал наш красивый логотип приложения!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Особая благодарность';
|
||||
|
||||
@@ -439,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get aboutFeatureRequestSubtitle =>
|
||||
'Предложить новые функции для приложения';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Поддержка';
|
||||
|
||||
@@ -489,9 +510,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -523,9 +544,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count релизов',
|
||||
one: '1 релиз',
|
||||
many: '$count релизов',
|
||||
few: '$count релиза',
|
||||
one: '$count релиз',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -901,9 +922,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -916,6 +937,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return '\"$trackName\" добавлен в очередь';
|
||||
@@ -946,9 +972,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалено $count $_temp0';
|
||||
}
|
||||
@@ -1095,9 +1121,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -1455,6 +1481,35 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'Настройки файла';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Цвет';
|
||||
|
||||
@@ -1510,9 +1565,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -1533,7 +1588,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackFileInfo => 'Информация о файле';
|
||||
|
||||
@override
|
||||
String get trackLyrics => 'Тексты песен';
|
||||
String get trackLyrics => 'Текст песни';
|
||||
|
||||
@override
|
||||
String get trackFileNotFound => 'Файл не найден';
|
||||
@@ -1545,7 +1600,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackOpenInSpotify => 'Открыть в Spotify';
|
||||
|
||||
@override
|
||||
String get trackTrackName => 'Название трека';
|
||||
String get trackTrackName => 'Название';
|
||||
|
||||
@override
|
||||
String get trackArtist => 'Исполнитель';
|
||||
@@ -1571,6 +1626,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Дата выхода';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Скачано';
|
||||
|
||||
@@ -1588,6 +1652,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
|
||||
|
||||
@@ -1820,6 +1893,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Фактическое качество зависит от доступности треков в сервисе';
|
||||
@@ -1967,6 +2056,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get albumFolderYearAlbumSubtitle =>
|
||||
'Альбомы/[2005] Название Альбома /';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||
|
||||
@@ -1976,9 +2072,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -2008,9 +2104,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -2018,6 +2114,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Функции утилиты';
|
||||
|
||||
@@ -2042,4 +2143,70 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Ошибка: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
@@ -402,6 +405,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
|
||||
@@ -426,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@@ -894,6 +915,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
@@ -1427,6 +1453,35 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@@ -1539,6 +1594,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@@ -1554,6 +1618,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -1782,6 +1855,22 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1926,6 +2015,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1973,6 +2069,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
|
||||
@@ -1997,6 +2098,72 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
@@ -4035,7 +4202,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
|
||||
@override
|
||||
String get homeRecent => 'Recent';
|
||||
String get homeRecent => '最新的';
|
||||
|
||||
@override
|
||||
String get historyTitle => 'History';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"@@locale": "de",
|
||||
"@@last_modified": "2026-01-17",
|
||||
"@@last_modified": "2026-01-16",
|
||||
"appName": "SpotiFLAC",
|
||||
"@appName": {
|
||||
"description": "App name - DO NOT TRANSLATE"
|
||||
@@ -131,7 +131,7 @@
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
},
|
||||
"settingsDownload": "Download",
|
||||
"settingsDownload": "Herunterladen",
|
||||
"@settingsDownload": {
|
||||
"description": "Settings section - download options"
|
||||
},
|
||||
@@ -151,7 +151,7 @@
|
||||
"@settingsAbout": {
|
||||
"description": "Settings section - app info"
|
||||
},
|
||||
"downloadTitle": "Download",
|
||||
"downloadTitle": "Herunterladen",
|
||||
"@downloadTitle": {
|
||||
"description": "Download settings page title"
|
||||
},
|
||||
@@ -508,11 +508,11 @@
|
||||
"@aboutOriginalCreator": {
|
||||
"description": "Role description for original creator"
|
||||
},
|
||||
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
||||
"aboutLogoArtist": "Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!",
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"aboutSpecialThanks": "Besonderer Dank",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
},
|
||||
@@ -520,27 +520,27 @@
|
||||
"@aboutLinks": {
|
||||
"description": "Section for external links"
|
||||
},
|
||||
"aboutMobileSource": "Mobile source code",
|
||||
"aboutMobileSource": "Mobiler Quellcode",
|
||||
"@aboutMobileSource": {
|
||||
"description": "Link to mobile GitHub repo"
|
||||
},
|
||||
"aboutPCSource": "PC source code",
|
||||
"aboutPCSource": "PC Quellcode",
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"aboutReportIssue": "Problem melden",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
},
|
||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||
"aboutReportIssueSubtitle": "Melde jedes Problem, die dir auftreten",
|
||||
"@aboutReportIssueSubtitle": {
|
||||
"description": "Subtitle for report issue"
|
||||
},
|
||||
"aboutFeatureRequest": "Feature request",
|
||||
"aboutFeatureRequest": "Feature vorschlagen",
|
||||
"@aboutFeatureRequest": {
|
||||
"description": "Link to suggest features"
|
||||
},
|
||||
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
||||
"aboutFeatureRequestSubtitle": "Schlage neue Funktionen für die App vor",
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
@@ -548,11 +548,11 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
@@ -564,11 +564,11 @@
|
||||
"@aboutVersion": {
|
||||
"description": "Version info label"
|
||||
},
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
||||
"aboutBinimumDesc": "Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!",
|
||||
"@aboutBinimumDesc": {
|
||||
"description": "Credit description for binimum"
|
||||
},
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
||||
"aboutSachinsenalDesc": "Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!",
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -576,7 +576,7 @@
|
||||
"@aboutDoubleDouble": {
|
||||
"description": "Name of Amazon API service - DO NOT TRANSLATE"
|
||||
},
|
||||
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
|
||||
"aboutDoubleDoubleDesc": "Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!",
|
||||
"@aboutDoubleDoubleDesc": {
|
||||
"description": "Credit for DoubleDouble API"
|
||||
},
|
||||
@@ -584,7 +584,7 @@
|
||||
"@aboutDabMusic": {
|
||||
"description": "Name of Qobuz API service - DO NOT TRANSLATE"
|
||||
},
|
||||
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
|
||||
"aboutDabMusicDesc": "Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!",
|
||||
"@aboutDabMusicDesc": {
|
||||
"description": "Credit for DAB Music API"
|
||||
},
|
||||
@@ -642,6 +642,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"artistPopular": "Popular",
|
||||
"@artistPopular": {
|
||||
"description": "Section header for popular/top tracks"
|
||||
},
|
||||
"artistMonthlyListeners": "{count} monthly listeners",
|
||||
"@artistMonthlyListeners": {
|
||||
"description": "Monthly listener count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String",
|
||||
"description": "Formatted listener count"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackMetadataTitle": "Track Info",
|
||||
"@trackMetadataTitle": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
},
|
||||
"recentTypeArtist": "Artist",
|
||||
"@recentTypeArtist": {
|
||||
"description": "Recent access item type - artist"
|
||||
},
|
||||
"recentTypeAlbum": "Album",
|
||||
"@recentTypeAlbum": {
|
||||
"description": "Recent access item type - album"
|
||||
},
|
||||
"recentTypeSong": "Song",
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"@recentTypePlaylist": {
|
||||
"description": "Recent access item type - playlist"
|
||||
},
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
"description": "Snackbar message when tapping playlist in recent access",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"description": "Playlist name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorGeneric": "Error: {message}",
|
||||
"@errorGeneric": {
|
||||
"description": "Generic error message format",
|
||||
"placeholders": {
|
||||
"message": {
|
||||
"type": "String",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,10 @@
|
||||
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
|
||||
"historyNoSingles": "No single downloads",
|
||||
"@historyNoSingles": {"description": "Empty state when filtering singles"},
|
||||
"historyNoSinglesSubtitle": "Single track downloads will appear here",
|
||||
"historyNoSinglesSubtitle": "Single track downloads will appear here",
|
||||
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {"description": "Search bar placeholder in history"},
|
||||
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {"description": "Settings screen title"},
|
||||
@@ -290,6 +292,8 @@
|
||||
"@aboutOriginalCreator": {"description": "Role description for original creator"},
|
||||
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
||||
"@aboutLogoArtist": {"description": "Role description for logo artist"},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {"description": "Section for translators"},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {"description": "Section for special thanks"},
|
||||
"aboutLinks": "Links",
|
||||
@@ -302,10 +306,20 @@
|
||||
"@aboutReportIssue": {"description": "Link to report bugs"},
|
||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
|
||||
"aboutFeatureRequest": "Feature request",
|
||||
"aboutFeatureRequest": "Feature request",
|
||||
"@aboutFeatureRequest": {"description": "Link to suggest features"},
|
||||
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
||||
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {"description": "Link to Telegram channel"},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {"description": "Link to Telegram chat group"},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {"description": "Section for social links"},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
@@ -617,6 +631,13 @@
|
||||
"dialogImportPlaylistTitle": "Import Playlist",
|
||||
"@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1049,6 +1070,26 @@
|
||||
"@sectionAudioQuality": {"description": "Settings section header"},
|
||||
"sectionFileSettings": "File Settings",
|
||||
"@sectionFileSettings": {"description": "Settings section header"},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {"description": "Settings section header"},
|
||||
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {"description": "Setting - how to save lyrics"},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {"description": "Lyrics mode picker description"},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {"description": "Lyrics mode option - embed in audio file"},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {"description": "Subtitle for embed option"},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {"description": "Lyrics mode option - separate LRC file"},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {"description": "Subtitle for external option"},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {"description": "Lyrics mode option - embed and external"},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {"description": "Subtitle for both option"},
|
||||
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {"description": "Settings section header"},
|
||||
"sectionTheme": "Theme",
|
||||
@@ -1131,6 +1172,12 @@
|
||||
"@trackAudioQuality": {"description": "Metadata label - audio quality"},
|
||||
"trackReleaseDate": "Release date",
|
||||
"@trackReleaseDate": {"description": "Metadata label - release date"},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {"description": "Metadata label - music genre"},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {"description": "Metadata label - record label"},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {"description": "Metadata label - copyright information"},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {"description": "Metadata label - download date"},
|
||||
"trackCopyLyrics": "Copy lyrics",
|
||||
@@ -1141,6 +1188,12 @@
|
||||
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
|
||||
"trackLyricsLoadFailed": "Failed to load lyrics",
|
||||
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
|
||||
"trackDeleteConfirmTitle": "Remove from device?",
|
||||
@@ -1320,6 +1373,16 @@
|
||||
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
|
||||
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
|
||||
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {"description": "Quality option - MP3 lossy format"},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {"description": "Note about quality availability"},
|
||||
|
||||
@@ -1420,6 +1483,10 @@
|
||||
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
|
||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
|
||||
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
|
||||
@@ -1459,6 +1526,13 @@
|
||||
},
|
||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
||||
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {"type": "int", "example": "1"}
|
||||
}
|
||||
},
|
||||
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {"description": "Extension capability - utility functions"},
|
||||
@@ -1485,5 +1559,80 @@
|
||||
"placeholders": {
|
||||
"message": {"type": "String", "description": "Error message"}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {"description": "Button - download artist discography"},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {"description": "Option - download entire discography"},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"albumCount": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {"description": "Option - download only albums"},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"albumCount": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {"description": "Option - download only singles"},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"albumCount": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {"description": "Option - manually select albums to download"},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {"description": "Progress - fetching album tracks"},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {"type": "int"},
|
||||
"skipped": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
|
||||
}
|
||||
|
||||
@@ -642,6 +642,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"artistPopular": "Popular",
|
||||
"@artistPopular": {
|
||||
"description": "Section header for popular/top tracks"
|
||||
},
|
||||
"artistMonthlyListeners": "{count} monthly listeners",
|
||||
"@artistMonthlyListeners": {
|
||||
"description": "Monthly listener count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String",
|
||||
"description": "Formatted listener count"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackMetadataTitle": "Track Info",
|
||||
"@trackMetadataTitle": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
},
|
||||
"recentTypeArtist": "Artist",
|
||||
"@recentTypeArtist": {
|
||||
"description": "Recent access item type - artist"
|
||||
},
|
||||
"recentTypeAlbum": "Album",
|
||||
"@recentTypeAlbum": {
|
||||
"description": "Recent access item type - album"
|
||||
},
|
||||
"recentTypeSong": "Song",
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"@recentTypePlaylist": {
|
||||
"description": "Recent access item type - playlist"
|
||||
},
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
"description": "Snackbar message when tapping playlist in recent access",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"description": "Playlist name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorGeneric": "Error: {message}",
|
||||
"@errorGeneric": {
|
||||
"description": "Generic error message format",
|
||||
"placeholders": {
|
||||
"message": {
|
||||
"type": "String",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,6 +642,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"artistPopular": "Popular",
|
||||
"@artistPopular": {
|
||||
"description": "Section header for popular/top tracks"
|
||||
},
|
||||
"artistMonthlyListeners": "{count} monthly listeners",
|
||||
"@artistMonthlyListeners": {
|
||||
"description": "Monthly listener count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String",
|
||||
"description": "Formatted listener count"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackMetadataTitle": "Track Info",
|
||||
"@trackMetadataTitle": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
},
|
||||
"recentTypeArtist": "Artist",
|
||||
"@recentTypeArtist": {
|
||||
"description": "Recent access item type - artist"
|
||||
},
|
||||
"recentTypeAlbum": "Album",
|
||||
"@recentTypeAlbum": {
|
||||
"description": "Recent access item type - album"
|
||||
},
|
||||
"recentTypeSong": "Song",
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"@recentTypePlaylist": {
|
||||
"description": "Recent access item type - playlist"
|
||||
},
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
"description": "Snackbar message when tapping playlist in recent access",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"description": "Playlist name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorGeneric": "Error: {message}",
|
||||
"@errorGeneric": {
|
||||
"description": "Generic error message format",
|
||||
"placeholders": {
|
||||
"message": {
|
||||
"type": "String",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,11 @@
|
||||
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
|
||||
"qualityHiResFlacMax": "Hi-Res FLAC Max",
|
||||
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
|
||||
"qualityMp3": "MP3",
|
||||
"qualityMp3Subtitle": "320kbps (konversi dari FLAC)",
|
||||
"enableMp3Option": "Aktifkan Opsi MP3",
|
||||
"enableMp3OptionSubtitleOn": "Opsi kualitas MP3 tersedia",
|
||||
"enableMp3OptionSubtitleOff": "Unduh FLAC lalu konversi ke MP3 320kbps",
|
||||
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
|
||||
|
||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||
@@ -660,6 +665,7 @@
|
||||
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
|
||||
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
|
||||
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
|
||||
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
|
||||
"folderOrganizationNone": "Tidak ada",
|
||||
@@ -677,5 +683,23 @@
|
||||
"recentTypePlaylist": "Playlist",
|
||||
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"errorGeneric": "Error: {message}"
|
||||
"errorGeneric": "Error: {message}",
|
||||
|
||||
"discographyDownload": "Unduh Diskografi",
|
||||
"discographyDownloadAll": "Unduh Semua",
|
||||
"discographyDownloadAllSubtitle": "{count} lagu dari {albumCount} rilis",
|
||||
"discographyAlbumsOnly": "Album Saja",
|
||||
"discographyAlbumsOnlySubtitle": "{count} lagu dari {albumCount} album",
|
||||
"discographySinglesOnly": "Single & EP Saja",
|
||||
"discographySinglesOnlySubtitle": "{count} lagu dari {albumCount} single",
|
||||
"discographySelectAlbums": "Pilih Album...",
|
||||
"discographySelectAlbumsSubtitle": "Pilih album atau single tertentu",
|
||||
"discographyFetchingTracks": "Mengambil lagu...",
|
||||
"discographyFetchingAlbum": "Mengambil {current} dari {total}...",
|
||||
"discographySelectedCount": "{count} dipilih",
|
||||
"discographyDownloadSelected": "Unduh yang Dipilih",
|
||||
"discographyAddedToQueue": "Menambahkan {count} lagu ke antrian",
|
||||
"discographySkippedDownloaded": "{added} ditambahkan, {skipped} sudah diunduh",
|
||||
"discographyNoAlbums": "Tidak ada album tersedia",
|
||||
"discographyFailedToFetch": "Gagal mengambil beberapa album"
|
||||
}
|
||||
|
||||
@@ -642,6 +642,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"artistPopular": "Popular",
|
||||
"@artistPopular": {
|
||||
"description": "Section header for popular/top tracks"
|
||||
},
|
||||
"artistMonthlyListeners": "{count} monthly listeners",
|
||||
"@artistMonthlyListeners": {
|
||||
"description": "Monthly listener count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String",
|
||||
"description": "Formatted listener count"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackMetadataTitle": "Track Info",
|
||||
"@trackMetadataTitle": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
},
|
||||
"recentTypeArtist": "Artist",
|
||||
"@recentTypeArtist": {
|
||||
"description": "Recent access item type - artist"
|
||||
},
|
||||
"recentTypeAlbum": "Album",
|
||||
"@recentTypeAlbum": {
|
||||
"description": "Recent access item type - album"
|
||||
},
|
||||
"recentTypeSong": "Song",
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"@recentTypePlaylist": {
|
||||
"description": "Recent access item type - playlist"
|
||||
},
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
"description": "Snackbar message when tapping playlist in recent access",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"description": "Playlist name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorGeneric": "Error: {message}",
|
||||
"@errorGeneric": {
|
||||
"description": "Generic error message format",
|
||||
"placeholders": {
|
||||
"message": {
|
||||
"type": "String",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,6 +642,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"artistPopular": "Popular",
|
||||
"@artistPopular": {
|
||||
"description": "Section header for popular/top tracks"
|
||||
},
|
||||
"artistMonthlyListeners": "{count} monthly listeners",
|
||||
"@artistMonthlyListeners": {
|
||||
"description": "Monthly listener count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String",
|
||||
"description": "Formatted listener count"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackMetadataTitle": "Track Info",
|
||||
"@trackMetadataTitle": {
|
||||
"description": "Track metadata screen title"
|
||||
@@ -1851,27 +1865,15 @@
|
||||
},
|
||||
"sectionLanguage": "Language",
|
||||
"@sectionLanguage": {
|
||||
"description": "Settings section header for language selection"
|
||||
"description": "Settings section header for language"
|
||||
},
|
||||
"appearanceLanguage": "App Language",
|
||||
"@appearanceLanguage": {
|
||||
"description": "Setting title for language selection"
|
||||
"description": "Language setting title"
|
||||
},
|
||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||
"@appearanceLanguageSubtitle": {
|
||||
"description": "Subtitle for language setting"
|
||||
},
|
||||
"languageSystem": "System Default",
|
||||
"@languageSystem": {
|
||||
"description": "Use device system language"
|
||||
},
|
||||
"languageEnglish": "English",
|
||||
"@languageEnglish": {
|
||||
"description": "English language option"
|
||||
},
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
"@languageIndonesian": {
|
||||
"description": "Indonesian language option"
|
||||
"description": "Language setting subtitle"
|
||||
},
|
||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||
"@settingsAppearanceSubtitle": {
|
||||
@@ -2573,5 +2575,41 @@
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
},
|
||||
"recentTypeArtist": "Artist",
|
||||
"@recentTypeArtist": {
|
||||
"description": "Recent access item type - artist"
|
||||
},
|
||||
"recentTypeAlbum": "Album",
|
||||
"@recentTypeAlbum": {
|
||||
"description": "Recent access item type - album"
|
||||
},
|
||||
"recentTypeSong": "Song",
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"@recentTypePlaylist": {
|
||||
"description": "Recent access item type - playlist"
|
||||
},
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
"description": "Snackbar message when tapping playlist in recent access",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"description": "Playlist name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorGeneric": "Error: {message}",
|
||||
"@errorGeneric": {
|
||||
"description": "Generic error message format",
|
||||
"placeholders": {
|
||||
"message": {
|
||||
"type": "String",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}",
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -596,7 +596,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -633,7 +633,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}",
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1108,7 +1108,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1169,7 +1169,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1376,7 +1376,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -1916,7 +1916,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -1945,7 +1945,7 @@
|
||||
"@trackFileInfo": {
|
||||
"description": "Tab title - file information"
|
||||
},
|
||||
"trackLyrics": "Тексты песен",
|
||||
"trackLyrics": "Текст песни",
|
||||
"@trackLyrics": {
|
||||
"description": "Tab title - lyrics"
|
||||
},
|
||||
@@ -1961,7 +1961,7 @@
|
||||
"@trackOpenInSpotify": {
|
||||
"description": "Action - open track in Spotify app"
|
||||
},
|
||||
"trackTrackName": "Название трека",
|
||||
"trackTrackName": "Название",
|
||||
"@trackTrackName": {
|
||||
"description": "Metadata label - track title"
|
||||
},
|
||||
@@ -2520,7 +2520,7 @@
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2559,7 +2559,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"@@locale": "tr",
|
||||
"@@last_modified": "2026-01-21",
|
||||
|
||||
"appName": "SpotiFLAC",
|
||||
"@appName": {"description": "App name - DO NOT TRANSLATE"}
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
"@homeSupports": {
|
||||
"description": "Info text about supported URL types"
|
||||
},
|
||||
"homeRecent": "Recent",
|
||||
"homeRecent": "最新的",
|
||||
"@homeRecent": {
|
||||
"description": "Section header for recent searches"
|
||||
},
|
||||
|
||||
@@ -1,48 +1,30 @@
|
||||
// GENERATED FILE - DO NOT EDIT
|
||||
// Generated by: dart run tool/check_translations.dart 0
|
||||
// Only languages with >= 0% translation completion are included.
|
||||
// Generated by: dart run tool/check_translations.dart 70
|
||||
// Only languages with >= 70% translation completion are included.
|
||||
// Translation is measured by comparing VALUES (not just key existence).
|
||||
//
|
||||
// To regenerate, run: dart run tool/check_translations.dart 0
|
||||
// To regenerate, run: dart run tool/check_translations.dart 70
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Minimum translation completion threshold used to filter languages.
|
||||
const int translationThreshold = 0;
|
||||
const int translationThreshold = 70;
|
||||
|
||||
/// List of locales that meet the translation threshold.
|
||||
/// Only these languages will be available in the app.
|
||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('ru'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('id'),
|
||||
Locale('ja'),
|
||||
Locale('de'),
|
||||
Locale('es'),
|
||||
Locale('fr'),
|
||||
Locale('hi'),
|
||||
Locale('ko'),
|
||||
Locale('nl'),
|
||||
Locale('pt'),
|
||||
Locale('zh'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
Locale('pt', 'PT'),
|
||||
];
|
||||
|
||||
/// Set of locale codes for quick lookup.
|
||||
const Set<String> filteredLocaleCodes = <String>{
|
||||
'en',
|
||||
'ru',
|
||||
'es_ES',
|
||||
'id',
|
||||
'ja',
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'hi',
|
||||
'ko',
|
||||
'nl',
|
||||
'pt',
|
||||
'zh',
|
||||
'zh_CN',
|
||||
'zh_TW',
|
||||
'pt_PT',
|
||||
};
|
||||
|
||||
@@ -7,13 +7,18 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await NotificationService().initialize();
|
||||
await CoverCacheManager.initialize();
|
||||
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
|
||||
|
||||
await ShareIntentService().initialize();
|
||||
await Future.wait([
|
||||
NotificationService().initialize(),
|
||||
ShareIntentService().initialize(),
|
||||
]);
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
|
||||
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
|
||||
part 'download_item.g.dart';
|
||||
|
||||
/// Download status enum
|
||||
enum DownloadStatus {
|
||||
queued,
|
||||
downloading,
|
||||
finalizing, // Embedding metadata, cover, lyrics
|
||||
finalizing,
|
||||
completed,
|
||||
failed,
|
||||
skipped,
|
||||
}
|
||||
|
||||
/// Error type enum for better error handling
|
||||
enum DownloadErrorType {
|
||||
unknown,
|
||||
notFound, // Track not found on any service
|
||||
rateLimit, // Rate limited by service
|
||||
network, // Network/connection error
|
||||
permission, // File/folder permission error
|
||||
notFound,
|
||||
rateLimit,
|
||||
network,
|
||||
permission,
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -29,7 +27,7 @@ class DownloadItem {
|
||||
final String service;
|
||||
final DownloadStatus status;
|
||||
final double progress;
|
||||
final double speedMBps; // Download speed in MB/s
|
||||
final double speedMBps;
|
||||
final String? filePath;
|
||||
final String? error;
|
||||
final DownloadErrorType? errorType;
|
||||
@@ -78,7 +76,6 @@ class DownloadItem {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message based on error type
|
||||
String get errorMessage {
|
||||
if (error == null) return '';
|
||||
|
||||
|
||||
@@ -12,25 +12,27 @@ class AppSettings {
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||
final bool checkForUpdates; // Check for updates on app start
|
||||
final String updateChannel; // stable, preview
|
||||
final bool hasSearchedBefore; // Hide helper text after first search
|
||||
final String folderOrganization; // none, artist, album, artist_album
|
||||
final String historyViewMode; // list, grid
|
||||
final String historyFilterMode; // all, albums, singles
|
||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
final bool enableLogging; // Enable detailed logging for debugging
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
final String locale; // App language: 'system', 'en', 'id', etc.
|
||||
final int concurrentDownloads;
|
||||
final bool checkForUpdates;
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
final String folderOrganization;
|
||||
final String historyViewMode;
|
||||
final String historyFilterMode;
|
||||
final bool askQualityBeforeDownload;
|
||||
final String spotifyClientId;
|
||||
final String spotifyClientSecret;
|
||||
final bool useCustomSpotifyCredentials;
|
||||
final String metadataSource;
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final String? searchProvider;
|
||||
final bool separateSingles;
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final bool enableMp3Option;
|
||||
final String lyricsMode;
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -41,25 +43,27 @@ class AppSettings {
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||
this.checkForUpdates = true, // Default: enabled
|
||||
this.updateChannel = 'stable', // Default: stable releases only
|
||||
this.hasSearchedBefore = false, // Default: show helper text
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.historyFilterMode = 'all', // Default: show all
|
||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
this.enableLogging = false, // Default: disabled for performance
|
||||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
this.separateSingles = false, // Default: disabled
|
||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
this.locale = 'system', // Default: follow system language
|
||||
this.concurrentDownloads = 1,
|
||||
this.checkForUpdates = true,
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
this.folderOrganization = 'none',
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.askQualityBeforeDownload = true,
|
||||
this.spotifyClientId = '',
|
||||
this.spotifyClientSecret = '',
|
||||
this.useCustomSpotifyCredentials = true,
|
||||
this.metadataSource = 'deezer',
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.searchProvider,
|
||||
this.separateSingles = false,
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
this.locale = 'system',
|
||||
this.enableMp3Option = false,
|
||||
this.lyricsMode = 'embed',
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -86,11 +90,13 @@ class AppSettings {
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
||||
bool clearSearchProvider = false,
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
bool? enableMp3Option,
|
||||
String? lyricsMode,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -120,6 +126,8 @@ class AppSettings {
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -67,4 +69,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'enableMp3Option': instance.enableMp3Option,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
|
||||
/// Default Spotify green color for fallback
|
||||
const int kDefaultSeedColor = 0xFF1DB954;
|
||||
|
||||
/// Theme settings model for Material Expressive 3
|
||||
class ThemeSettings {
|
||||
final ThemeMode themeMode;
|
||||
final bool useDynamicColor;
|
||||
@@ -23,10 +22,8 @@ class ThemeSettings {
|
||||
this.useAmoled = false,
|
||||
});
|
||||
|
||||
/// Get seed color as Color object
|
||||
Color get seedColor => Color(seedColorValue);
|
||||
|
||||
/// Create a copy with updated values
|
||||
ThemeSettings copyWith({
|
||||
ThemeMode? themeMode,
|
||||
bool? useDynamicColor,
|
||||
@@ -41,7 +38,6 @@ class ThemeSettings {
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON map for persistence
|
||||
Map<String, dynamic> toJson() => {
|
||||
kThemeModeKey: themeMode.name,
|
||||
kUseDynamicColorKey: useDynamicColor,
|
||||
@@ -49,7 +45,6 @@ class ThemeSettings {
|
||||
kUseAmoledKey: useAmoled,
|
||||
};
|
||||
|
||||
/// Create from JSON map
|
||||
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
||||
return ThemeSettings(
|
||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||
@@ -74,7 +69,6 @@ class ThemeSettings {
|
||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||
}
|
||||
|
||||
/// Helper to convert string to ThemeMode
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'track.g.dart';
|
||||
|
||||
/// Track model representing a music track
|
||||
@JsonSerializable()
|
||||
class Track {
|
||||
final String id;
|
||||
@@ -18,9 +17,9 @@ class Track {
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||
final String? itemType; // track, album, playlist - for extension search results
|
||||
final String? source;
|
||||
final String? albumType;
|
||||
final String? itemType;
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -41,25 +40,19 @@ class Track {
|
||||
this.itemType,
|
||||
});
|
||||
|
||||
/// Check if this track is a single (based on album_type metadata)
|
||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||
|
||||
/// Check if this is an album item (not a track)
|
||||
bool get isAlbumItem => itemType == 'album';
|
||||
|
||||
/// Check if this is a playlist item (not a track)
|
||||
bool get isPlaylistItem => itemType == 'playlist';
|
||||
|
||||
/// Check if this is an artist item (not a track)
|
||||
bool get isArtistItem => itemType == 'artist';
|
||||
|
||||
/// Check if this is a collection (album, playlist, or artist)
|
||||
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
/// Check if this track is from an extension
|
||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
/// Represents an item in a Spotify home section
|
||||
class ExploreItem {
|
||||
final String id;
|
||||
final String uri;
|
||||
final String type; // track, album, playlist, artist, station
|
||||
final String name;
|
||||
final String artists;
|
||||
final String? description;
|
||||
final String? coverUrl;
|
||||
final String? providerId;
|
||||
final String? albumId;
|
||||
final String? albumName;
|
||||
final int durationMs;
|
||||
|
||||
const ExploreItem({
|
||||
required this.id,
|
||||
required this.uri,
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.artists,
|
||||
this.description,
|
||||
this.coverUrl,
|
||||
this.providerId,
|
||||
this.albumId,
|
||||
this.albumName,
|
||||
this.durationMs = 0,
|
||||
});
|
||||
|
||||
factory ExploreItem.fromJson(Map<String, dynamic> json) {
|
||||
return ExploreItem(
|
||||
id: json['id'] as String? ?? '',
|
||||
uri: json['uri'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'track',
|
||||
name: json['name'] as String? ?? '',
|
||||
artists: json['artists'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
coverUrl: json['cover_url'] as String?,
|
||||
providerId: json['provider_id'] as String?,
|
||||
albumId: json['album_id'] as String?,
|
||||
albumName: json['album_name'] as String?,
|
||||
durationMs: json['duration_ms'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a section in Spotify home feed
|
||||
class ExploreSection {
|
||||
final String uri;
|
||||
final String title;
|
||||
final List<ExploreItem> items;
|
||||
final bool isYTMusicQuickPicks;
|
||||
|
||||
const ExploreSection({
|
||||
required this.uri,
|
||||
required this.title,
|
||||
required this.items,
|
||||
this.isYTMusicQuickPicks = false,
|
||||
});
|
||||
|
||||
factory ExploreSection.fromJson(Map<String, dynamic> json) {
|
||||
final itemsList = json['items'] as List<dynamic>? ?? [];
|
||||
final items = itemsList
|
||||
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
final isQuickPicks = _isYTMusicQuickPicksItems(items);
|
||||
return ExploreSection(
|
||||
uri: json['uri'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
items: items,
|
||||
isYTMusicQuickPicks: isQuickPicks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for explore/home feed
|
||||
class ExploreState {
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final String? greeting;
|
||||
final List<ExploreSection> sections;
|
||||
final DateTime? lastFetched;
|
||||
|
||||
const ExploreState({
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.greeting,
|
||||
this.sections = const [],
|
||||
this.lastFetched,
|
||||
});
|
||||
|
||||
bool get hasContent => sections.isNotEmpty;
|
||||
|
||||
ExploreState copyWith({
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
String? greeting,
|
||||
List<ExploreSection>? sections,
|
||||
DateTime? lastFetched,
|
||||
}) {
|
||||
return ExploreState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
greeting: greeting ?? this.greeting,
|
||||
sections: sections ?? this.sections,
|
||||
lastFetched: lastFetched ?? this.lastFetched,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate greeting based on local device time
|
||||
String _getLocalGreeting() {
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour >= 5 && hour < 12) {
|
||||
return 'Good morning';
|
||||
} else if (hour >= 12 && hour < 17) {
|
||||
return 'Good afternoon';
|
||||
} else if (hour >= 17 && hour < 21) {
|
||||
return 'Good evening';
|
||||
} else {
|
||||
return 'Good night';
|
||||
}
|
||||
}
|
||||
|
||||
bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||
if (items.isEmpty) return false;
|
||||
if (items.first.providerId != 'ytmusic-spotiflac') return false;
|
||||
for (final item in items) {
|
||||
if (item.type != 'track') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Provider for explore/home feed state
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
@override
|
||||
ExploreState build() {
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
// Don't refetch if we have data and it's less than 5 minutes old
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
_log.d('Home feed fetch already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Find any extension with homeFeed capability
|
||||
final extState = ref.read(extensionProvider);
|
||||
_log.d('Extensions count: ${extState.extensions.length}');
|
||||
|
||||
// Look for extensions with homeFeed capability (prefer spotify-web)
|
||||
Extension? targetExt;
|
||||
for (final extension in extState.extensions) {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
continue;
|
||||
}
|
||||
if (targetExt == null || extension.id == 'spotify-web') {
|
||||
targetExt = extension;
|
||||
if (extension.id == 'spotify-web') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetExt == null) {
|
||||
_log.w('No extension with homeFeed capability found');
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'No extension with home feed support enabled',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||
|
||||
if (result == null) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to fetch home feed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final success = result['success'] as bool? ?? false;
|
||||
_log.d('getExtensionHomeFeed success=$success');
|
||||
if (!success) {
|
||||
final error = result['error'] as String? ?? 'Unknown error';
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final greeting = result['greeting'] as String?;
|
||||
final sectionsData = result['sections'] as List<dynamic>? ?? [];
|
||||
|
||||
final sections = sectionsData
|
||||
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
// Debug: log first section items
|
||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||
final firstItem = sections.first.items.first;
|
||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||
}
|
||||
|
||||
// Always use local device time for greeting to avoid timezone issues
|
||||
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
|
||||
final localGreeting = _getLocalGreeting();
|
||||
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
||||
|
||||
state = ExploreState(
|
||||
isLoading: false,
|
||||
greeting: localGreeting,
|
||||
sections: sections,
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cached data
|
||||
void clear() {
|
||||
state = const ExploreState();
|
||||
}
|
||||
|
||||
/// Refresh home feed
|
||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
|
||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||
return ExploreNotifier();
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
/// Represents an installed extension
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -14,19 +13,20 @@ class Extension {
|
||||
final String author;
|
||||
final String description;
|
||||
final bool enabled;
|
||||
final String status; // 'loaded', 'error', 'disabled'
|
||||
final String status;
|
||||
final String? errorMessage;
|
||||
final String? iconPath; // Path to extension icon
|
||||
final String? iconPath;
|
||||
final List<String> permissions;
|
||||
final List<ExtensionSetting> settings;
|
||||
final List<QualityOption> qualityOptions; // Custom quality options for download providers
|
||||
final List<QualityOption> qualityOptions;
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior; // Custom search behavior
|
||||
final URLHandler? urlHandler; // Custom URL handling
|
||||
final TrackMatching? trackMatching; // Custom track matching
|
||||
final PostProcessing? postProcessing; // Post-processing hooks
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -49,6 +49,7 @@ class Extension {
|
||||
this.urlHandler,
|
||||
this.trackMatching,
|
||||
this.postProcessing,
|
||||
this.capabilities = const {},
|
||||
});
|
||||
|
||||
factory Extension.fromJson(Map<String, dynamic> json) {
|
||||
@@ -85,6 +86,7 @@ class Extension {
|
||||
postProcessing: json['post_processing'] != null
|
||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||
: null,
|
||||
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +111,7 @@ class Extension {
|
||||
URLHandler? urlHandler,
|
||||
TrackMatching? trackMatching,
|
||||
PostProcessing? postProcessing,
|
||||
Map<String, dynamic>? capabilities,
|
||||
}) {
|
||||
return Extension(
|
||||
id: id ?? this.id,
|
||||
@@ -131,6 +134,7 @@ class Extension {
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
postProcessing: postProcessing ?? this.postProcessing,
|
||||
capabilities: capabilities ?? this.capabilities,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,9 +142,10 @@ class Extension {
|
||||
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||
}
|
||||
|
||||
/// Custom search behavior configuration
|
||||
class SearchBehavior {
|
||||
final bool enabled;
|
||||
final String? placeholder;
|
||||
@@ -172,8 +177,6 @@ class SearchBehavior {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get thumbnail size based on configuration
|
||||
/// Returns (width, height) tuple
|
||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||
@@ -191,11 +194,10 @@ class SearchBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom track matching configuration
|
||||
class TrackMatching {
|
||||
final bool customMatching;
|
||||
final String? strategy; // "isrc", "name", "duration", "custom"
|
||||
final int durationTolerance; // in seconds
|
||||
final String? strategy;
|
||||
final int durationTolerance;
|
||||
|
||||
const TrackMatching({
|
||||
required this.customMatching,
|
||||
@@ -212,7 +214,6 @@ class TrackMatching {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-processing configuration
|
||||
class PostProcessing {
|
||||
final bool enabled;
|
||||
final List<PostProcessingHook> hooks;
|
||||
@@ -262,7 +263,6 @@ class URLHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// A post-processing hook
|
||||
class PostProcessingHook {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -289,12 +289,11 @@ class PostProcessingHook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a quality option for download providers
|
||||
class QualityOption {
|
||||
final String id;
|
||||
final String label;
|
||||
final String? description;
|
||||
final List<QualitySpecificSetting> settings; // Quality-specific settings
|
||||
final List<QualitySpecificSetting> settings;
|
||||
|
||||
const QualityOption({
|
||||
required this.id,
|
||||
@@ -315,14 +314,13 @@ class QualityOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting that's specific to a quality option
|
||||
class QualitySpecificSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final String type;
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final List<String>? options;
|
||||
final bool required;
|
||||
final bool secret;
|
||||
|
||||
@@ -351,15 +349,15 @@ class QualitySpecificSetting {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting field for an extension
|
||||
class ExtensionSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final String type;
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final List<String>? options;
|
||||
final bool required;
|
||||
final String? action;
|
||||
|
||||
const ExtensionSetting({
|
||||
required this.key,
|
||||
@@ -369,6 +367,7 @@ class ExtensionSetting {
|
||||
this.description,
|
||||
this.options,
|
||||
this.required = false,
|
||||
this.action,
|
||||
});
|
||||
|
||||
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
||||
@@ -380,11 +379,11 @@ class ExtensionSetting {
|
||||
description: json['description'] as String?,
|
||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||
required: json['required'] as bool? ?? false,
|
||||
action: json['action'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension management
|
||||
class ExtensionState {
|
||||
final List<Extension> extensions;
|
||||
final List<String> providerPriority;
|
||||
@@ -422,7 +421,6 @@ class ExtensionState {
|
||||
}
|
||||
|
||||
|
||||
/// Provider for managing extensions
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
@override
|
||||
ExtensionState build() {
|
||||
@@ -448,7 +446,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all extensions from directory
|
||||
Future<void> loadExtensions(String dirPath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -483,12 +480,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any error state
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
/// Install extension from file (auto-upgrades if already installed with newer version)
|
||||
Future<bool> installExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -505,8 +500,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a package file is an upgrade for an existing extension
|
||||
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
|
||||
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
try {
|
||||
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
||||
@@ -516,7 +509,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade an existing extension from a new package file
|
||||
Future<bool> upgradeExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -550,7 +542,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable an extension
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
@@ -597,7 +588,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update settings for an extension
|
||||
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
||||
@@ -618,7 +608,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set provider priority order
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
@@ -640,7 +629,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set metadata provider priority order
|
||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
@@ -662,7 +650,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension by ID
|
||||
Extension? getExtension(String extensionId) {
|
||||
try {
|
||||
return state.extensions.firstWhere((ext) => ext.id == extensionId);
|
||||
@@ -676,7 +663,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return state.extensions.where((ext) => ext.enabled).toList();
|
||||
}
|
||||
|
||||
/// Get all download providers (built-in + extensions)
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'amazon'];
|
||||
for (final ext in state.extensions) {
|
||||
@@ -697,7 +683,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
/// Get all extensions that provide custom search
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _recentAccessKey = 'recent_access_history';
|
||||
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
|
||||
const _maxRecentItems = 20;
|
||||
|
||||
/// Types of items that can be accessed
|
||||
@@ -75,19 +76,23 @@ class RecentAccessItem {
|
||||
/// State for recent access history
|
||||
class RecentAccessState {
|
||||
final List<RecentAccessItem> items;
|
||||
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
|
||||
final bool isLoaded;
|
||||
|
||||
const RecentAccessState({
|
||||
this.items = const [],
|
||||
this.hiddenDownloadIds = const {},
|
||||
this.isLoaded = false,
|
||||
});
|
||||
|
||||
RecentAccessState copyWith({
|
||||
List<RecentAccessItem>? items,
|
||||
Set<String>? hiddenDownloadIds,
|
||||
bool? isLoaded,
|
||||
}) {
|
||||
return RecentAccessState(
|
||||
items: items ?? this.items,
|
||||
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
);
|
||||
}
|
||||
@@ -95,6 +100,8 @@ class RecentAccessState {
|
||||
|
||||
/// Provider for managing recent access history
|
||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
RecentAccessState build() {
|
||||
_loadHistory();
|
||||
@@ -102,29 +109,41 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final json = prefs.getString(_recentAccessKey);
|
||||
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
||||
|
||||
List<RecentAccessItem> items = [];
|
||||
Set<String> hiddenIds = {};
|
||||
|
||||
if (json != null) {
|
||||
try {
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
final items = decoded
|
||||
items = decoded
|
||||
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = state.copyWith(items: items, isLoaded: true);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoaded: true);
|
||||
}
|
||||
} else {
|
||||
state = state.copyWith(isLoaded: true);
|
||||
}
|
||||
|
||||
if (hiddenJson != null) {
|
||||
hiddenIds = hiddenJson.toSet();
|
||||
}
|
||||
|
||||
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
|
||||
}
|
||||
|
||||
Future<void> _saveHistory() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_recentAccessKey, json);
|
||||
}
|
||||
|
||||
Future<void> _saveHiddenDownloads() async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
|
||||
}
|
||||
|
||||
/// Record an access to an artist
|
||||
void recordArtistAccess({
|
||||
required String id,
|
||||
@@ -200,9 +219,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
|
||||
void _recordAccess(RecentAccessItem item) {
|
||||
// ignore: avoid_print
|
||||
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
|
||||
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
.toList();
|
||||
@@ -215,9 +231,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_saveHistory();
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[RecentAccess] Total items now: ${updatedItems.length}');
|
||||
}
|
||||
|
||||
/// Remove a specific item from history
|
||||
@@ -229,14 +242,31 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
_saveHistory();
|
||||
}
|
||||
|
||||
/// Hide a download item from recents (without deleting the actual download)
|
||||
void hideDownloadFromRecents(String downloadId) {
|
||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||
_saveHiddenDownloads();
|
||||
}
|
||||
|
||||
/// Check if a download is hidden from recents
|
||||
bool isDownloadHidden(String downloadId) {
|
||||
return state.hiddenDownloadIds.contains(downloadId);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
void clearHistory() {
|
||||
state = state.copyWith(items: []);
|
||||
_saveHistory();
|
||||
}
|
||||
|
||||
/// Clear hidden downloads (show all again)
|
||||
void clearHiddenDownloads() {
|
||||
state = state.copyWith(hiddenDownloadIds: {});
|
||||
_saveHiddenDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider instance
|
||||
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
||||
RecentAccessNotifier.new,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 1;
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
_loadSettings();
|
||||
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
@@ -30,7 +32,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one-time migrations for settings
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
@@ -47,11 +48,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||
}
|
||||
|
||||
/// Apply current Spotify credentials to Go backend
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
@@ -92,6 +92,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLyricsMode(String mode) {
|
||||
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
||||
state = state.copyWith(lyricsMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
void setMaxQualityCover(bool enabled) {
|
||||
state = state.copyWith(maxQualityCover: enabled);
|
||||
_saveSettings();
|
||||
@@ -223,6 +230,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(locale: locale);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableMp3Option(bool enabled) {
|
||||
state = state.copyWith(enableMp3Option: enabled);
|
||||
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS
|
||||
if (!enabled && state.audioQuality == 'MP3') {
|
||||
state = state.copyWith(audioQuality: 'LOSSLESS');
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('StoreProvider');
|
||||
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||
|
||||
/// Compare two semantic version strings
|
||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
|
||||
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
|
||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
|
||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||
|
||||
@@ -52,7 +53,6 @@ class StoreCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an extension in the store
|
||||
class StoreExtension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -118,7 +118,6 @@ class StoreExtension {
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension store
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
@@ -200,7 +199,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
return const StoreState();
|
||||
}
|
||||
|
||||
/// Initialize the store
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
@@ -234,7 +232,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set category filter
|
||||
void setCategory(String? category) {
|
||||
if (category == null) {
|
||||
state = state.copyWith(clearCategory: true);
|
||||
@@ -248,7 +245,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
|
||||
/// Clear search
|
||||
void clearSearch() {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
@@ -279,7 +275,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an installed extension
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
@@ -305,7 +300,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
|
||||
|
||||
/// Notifier for managing theme settings with persistence
|
||||
class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
ThemeSettings build() {
|
||||
// Load settings asynchronously on first access
|
||||
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
/// Load theme settings from SharedPreferences
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
final modeString = prefs.getString(kThemeModeKey);
|
||||
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
||||
final seedColor = prefs.getInt(kSeedColorKey);
|
||||
@@ -34,14 +36,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error loading theme settings: $e');
|
||||
// Keep default state on error
|
||||
}
|
||||
}
|
||||
|
||||
/// Save current settings to SharedPreferences
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
||||
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
||||
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
||||
|
||||
@@ -89,7 +89,6 @@ class TrackState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an album in artist discography
|
||||
class ArtistAlbum {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -112,7 +111,6 @@ class ArtistAlbum {
|
||||
});
|
||||
}
|
||||
|
||||
/// Represents an artist in search results
|
||||
class SearchArtist {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -130,7 +128,6 @@ class SearchArtist {
|
||||
}
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
/// Request ID to track and cancel outdated requests
|
||||
int _currentRequestId = 0;
|
||||
|
||||
@override
|
||||
@@ -213,14 +210,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Metadata fetch success');
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Metadata fetch failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
@@ -263,7 +254,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: [], // No tracks for artist view
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
@@ -397,7 +388,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform custom search using a specific extension
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -429,7 +419,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: [], // Custom search doesn't return artists
|
||||
searchArtists: [],
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
@@ -486,10 +476,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
/// Set search text state for back button handling
|
||||
void setSearchText(bool hasText) {
|
||||
if (state.hasSearchText == hasText) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
/// Set recent access mode state
|
||||
void setShowingRecentAccess(bool showing) {
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
@@ -579,8 +571,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-warm track ID cache for faster downloads
|
||||
/// Runs in background, doesn't block UI
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -10,8 +12,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
|
||||
|
||||
/// Simple in-memory cache for album tracks
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
static const Duration _ttl = Duration(minutes: 10);
|
||||
@@ -37,12 +40,14 @@ class _CacheEntry {
|
||||
_CacheEntry(this.tracks, this.expiresAt);
|
||||
}
|
||||
|
||||
/// Album detail screen with Material Expressive 3 design
|
||||
class AlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final List<Track>? tracks; // Optional - will fetch if null
|
||||
final String? extensionId; // If from extension
|
||||
final String? artistId; // Artist ID for navigation
|
||||
final String? artistName; // Artist name for navigation
|
||||
|
||||
const AlbumScreen({
|
||||
super.key,
|
||||
@@ -50,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
this.tracks,
|
||||
this.extensionId,
|
||||
this.artistId,
|
||||
this.artistName,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -60,11 +68,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
String? _artistId;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
|
||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||
@@ -73,28 +87,68 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||
_artistId = widget.artistId; // Use provided artist ID if available
|
||||
|
||||
if (_tracks == null) {
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
_extractDominantColor();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null) return;
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null) {
|
||||
setState(() => _dominantColor = color);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatReleaseDate(String date) {
|
||||
// Handle formats: "2024-01-15", "2024-01", "2024"
|
||||
if (date.length >= 10) {
|
||||
// Full date: 2024-01-15
|
||||
final parts = date.substring(0, 10).split('-');
|
||||
if (parts.length == 3) {
|
||||
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
|
||||
}
|
||||
} else if (date.length >= 7) {
|
||||
// Month: 2024-01
|
||||
final parts = date.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[1]}/${parts[0]}'; // MM/YYYY
|
||||
}
|
||||
}
|
||||
return date; // Year only or unknown format
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
if (widget.albumId.startsWith('deezer:')) {
|
||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||
// ignore: avoid_print
|
||||
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||
} else {
|
||||
// ignore: avoid_print
|
||||
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
}
|
||||
@@ -102,11 +156,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
// Extract artist ID from album_info if available
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = albumInfo?['artist_id'] as String?;
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -143,6 +202,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
@@ -167,74 +227,106 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
colorScheme.surface.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.albumName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -242,8 +334,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -260,27 +355,61 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (tracks.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
if (artistName != null && artistName.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToArtist(context, artistName),
|
||||
child: Text(
|
||||
artistName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (tracks.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (releaseDate != null && releaseDate.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -359,11 +488,51 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
void _navigateToArtist(BuildContext context, String artistName) {
|
||||
// Use stored artist ID if available, otherwise use a placeholder
|
||||
final artistId = _artistId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
||||
|
||||
// Don't navigate if artist ID is unknown
|
||||
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Artist information not available')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If from extension, use ExtensionArtistScreen
|
||||
if (widget.extensionId != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
extensionId: widget.extensionId!,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistScreen(
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
@@ -426,7 +595,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _AlbumTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
final VoidCallback onDownload;
|
||||
@@ -437,9 +605,9 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
final queueItem = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
|
||||
);
|
||||
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
@@ -459,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.trackNumber ?? 0}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -13,6 +15,7 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Simple in-memory cache for artist data
|
||||
class _ArtistCache {
|
||||
@@ -95,11 +98,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
String? _headerImageUrl;
|
||||
int? _monthlyListeners;
|
||||
String? _error;
|
||||
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
// Selection mode state
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedAlbumIds = {};
|
||||
bool _isFetchingDiscography = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Setup scroll listener for sticky title
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = widget.extensionId ??
|
||||
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||
@@ -141,9 +155,24 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
} else {
|
||||
_fetchDiscography();
|
||||
}
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// Show title when scrolled past the header (280px trigger)
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchDiscography() async {
|
||||
setState(() => _isLoadingDiscography = true);
|
||||
try {
|
||||
@@ -256,10 +285,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelectionMode) {
|
||||
_exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildHeader(context, colorScheme),
|
||||
_buildHeader(context, colorScheme, albums: albums, hasDiscography: hasDiscography),
|
||||
if (_isLoadingDiscography)
|
||||
const SliverToBoxAdapter(child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
@@ -280,14 +321,444 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (compilations.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
// Add padding at bottom for selection bar
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
],
|
||||
),
|
||||
// Selection action bar
|
||||
if (_isSelectionMode)
|
||||
_buildSelectionBar(context, colorScheme, albums),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build Spotify-style header with full-width image and artist name overlay
|
||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
void _exitSelectionMode() {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedAlbumIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String albumId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
_selectedAlbumIds.add(albumId);
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleAlbumSelection(String albumId) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
if (_selectedAlbumIds.contains(albumId)) {
|
||||
_selectedAlbumIds.remove(albumId);
|
||||
if (_selectedAlbumIds.isEmpty) {
|
||||
_isSelectionMode = false;
|
||||
}
|
||||
} else {
|
||||
_selectedAlbumIds.add(albumId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAll(List<ArtistAlbum> albums) {
|
||||
setState(() {
|
||||
_selectedAlbumIds.addAll(albums.map((a) => a.id));
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAlbumIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildSelectionBar(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> allAlbums) {
|
||||
final allSelected = _selectedAlbumIds.length == allAlbums.length;
|
||||
final selectedCount = _selectedAlbumIds.length;
|
||||
final selectedAlbums = allAlbums.where((a) => _selectedAlbumIds.contains(a.id)).toList();
|
||||
final totalTracks = selectedAlbums.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Close button
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Selection info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Select all / Deselect button
|
||||
TextButton(
|
||||
onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums),
|
||||
child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Download button
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownloadSelected),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDiscographyOptions(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> albums) {
|
||||
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||
|
||||
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
final albumTracks = albumsOnly.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Title
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.download, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.l10n.discographyDownload,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Options
|
||||
if (albums.isNotEmpty)
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.library_music,
|
||||
title: context.l10n.discographyDownloadAll,
|
||||
subtitle: context.l10n.discographyDownloadAllSubtitle(totalTracks, albums.length),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_downloadAlbums(context, albums);
|
||||
},
|
||||
),
|
||||
if (albumsOnly.isNotEmpty)
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.album,
|
||||
title: context.l10n.discographyAlbumsOnly,
|
||||
subtitle: context.l10n.discographyAlbumsOnlySubtitle(albumTracks, albumsOnly.length),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_downloadAlbums(context, albumsOnly);
|
||||
},
|
||||
),
|
||||
if (singles.isNotEmpty)
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.music_note,
|
||||
title: context.l10n.discographySinglesOnly,
|
||||
subtitle: context.l10n.discographySinglesOnlySubtitle(singleTracks, singles.length),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_downloadAlbums(context, singles);
|
||||
},
|
||||
),
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.checklist,
|
||||
title: context.l10n.discographySelectAlbums,
|
||||
subtitle: context.l10n.discographySelectAlbumsSubtitle,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_enterSelectionMode(albums.first.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downloadAlbums(BuildContext context, List<ArtistAlbum> albums) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
onSelect: (quality, service) {
|
||||
_fetchAndQueueAlbums(albums, service, quality);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
_fetchAndQueueAlbums(albums, settings.defaultService, null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadSelectedAlbums(BuildContext context, List<ArtistAlbum> albums) async {
|
||||
_exitSelectionMode();
|
||||
await _downloadAlbums(context, albums);
|
||||
}
|
||||
|
||||
Future<void> _fetchAndQueueAlbums(
|
||||
List<ArtistAlbum> albums,
|
||||
String service,
|
||||
String? qualityOverride,
|
||||
) async {
|
||||
if (_isFetchingDiscography) return;
|
||||
|
||||
setState(() => _isFetchingDiscography = true);
|
||||
|
||||
// Show progress dialog
|
||||
if (!mounted) {
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _FetchingProgressDialog(
|
||||
totalAlbums: albums.length,
|
||||
onCancel: () {
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final allTracks = <Track>[];
|
||||
int fetchedCount = 0;
|
||||
int failedCount = 0;
|
||||
|
||||
// Fetch tracks from each album
|
||||
for (final album in albums) {
|
||||
if (!_isFetchingDiscography) break; // Cancelled
|
||||
|
||||
try {
|
||||
final tracks = await _fetchAlbumTracks(album);
|
||||
allTracks.addAll(tracks);
|
||||
} catch (e) {
|
||||
failedCount++;
|
||||
}
|
||||
|
||||
fetchedCount++;
|
||||
|
||||
// Update progress dialog
|
||||
if (mounted) {
|
||||
_FetchingProgressDialog.updateProgress(context, fetchedCount, albums.length);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
|
||||
// Close progress dialog
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
// Show warning if some albums failed
|
||||
if (failedCount > 0 && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
|
||||
);
|
||||
}
|
||||
|
||||
if (allTracks.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.discographyNoAlbums)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which tracks are already downloaded
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in allTracks) {
|
||||
final isDownloaded = historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null);
|
||||
|
||||
if (!isDownloaded) {
|
||||
tracksToQueue.add(track);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.discographySkippedDownloaded(0, skippedCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: qualityOverride,
|
||||
);
|
||||
|
||||
// Show success message
|
||||
if (mounted) {
|
||||
final message = skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount)
|
||||
: context.l10n.discographyAddedToQueue(tracksToQueue.length);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.snackbarViewQueue,
|
||||
onPressed: () {
|
||||
// Navigate to queue tab (index 1)
|
||||
// This will be handled by the navigation system
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
|
||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||
// Extension album
|
||||
final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} else if (album.id.startsWith('deezer:')) {
|
||||
// Deezer album
|
||||
final deezerId = album.id.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId);
|
||||
if (metadata['tracks'] != null) {
|
||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album)).toList();
|
||||
}
|
||||
} else {
|
||||
// Spotify album
|
||||
final url = 'https://open.spotify.com/album/${album.id}';
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
// Fallback to direct Spotify metadata
|
||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
if (metadata['tracks'] != null) {
|
||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue * 1000; // Deezer returns seconds
|
||||
} else if (durationValue is double) {
|
||||
durationMs = (durationValue * 1000).toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: 'deezer:${data['id']}',
|
||||
name: (data['title'] ?? data['name'] ?? '').toString(),
|
||||
artistName: (data['artist']?['name'] ?? data['artist'] ?? widget.artistName).toString(),
|
||||
albumName: album.name,
|
||||
albumArtist: widget.artistName,
|
||||
coverUrl: album.coverUrl,
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_position'] as int? ?? data['track_number'] as int?,
|
||||
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
||||
releaseDate: album.releaseDate,
|
||||
albumType: album.albumType,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme, {
|
||||
required List<ArtistAlbum> albums,
|
||||
required bool hasDiscography,
|
||||
}) {
|
||||
String? imageUrl = _headerImageUrl;
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
imageUrl = widget.headerImageUrl;
|
||||
@@ -307,22 +778,38 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 380,
|
||||
return SliverAppBar(
|
||||
expandedHeight: hasDiscography ? 420 : 380,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.artistName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (hasValidImage)
|
||||
if (hasValidImage)
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter, // Show top of image (faces)
|
||||
memCacheWidth: 800,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
@@ -391,6 +878,26 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
// Download Discography button
|
||||
if (hasDiscography && !_isSelectionMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _showDiscographyOptions(context, colorScheme, albums),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownload),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -439,11 +946,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a single popular track item with dynamic download status
|
||||
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
|
||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
final queueItem = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
|
||||
);
|
||||
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
@@ -477,12 +983,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: track.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
@@ -567,7 +1074,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
_downloadTrack(track);
|
||||
}
|
||||
|
||||
/// Build download button with status indicator for popular tracks
|
||||
Widget _buildPopularDownloadButton({
|
||||
required Track track,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -702,23 +1208,39 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||
final isSelected = _selectedAlbumIds.contains(album.id);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToAlbum(album),
|
||||
onTap: () {
|
||||
if (_isSelectionMode) {
|
||||
_toggleAlbumSelection(album.id);
|
||||
} else {
|
||||
_navigateToAlbum(album);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
if (!_isSelectionMode) {
|
||||
_enterSelectionMode(album.id);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
? CachedNetworkImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
width: 140,
|
||||
height: 140,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 280,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
@@ -737,6 +1259,50 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
|
||||
),
|
||||
),
|
||||
// Selection overlay
|
||||
if (_isSelectionMode)
|
||||
Positioned.fill(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: isSelected
|
||||
? colorScheme.primary.withValues(alpha: 0.3)
|
||||
: Colors.black.withValues(alpha: 0.1),
|
||||
border: isSelected
|
||||
? Border.all(color: colorScheme.primary, width: 3)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Checkbox
|
||||
if (_isSelectionMode)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.surface.withValues(alpha: 0.9),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 18)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
@@ -848,3 +1414,143 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Option tile for discography download bottom sheet
|
||||
class _DiscographyOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DiscographyOptionTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 24),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress dialog shown while fetching album tracks
|
||||
class _FetchingProgressDialog extends StatefulWidget {
|
||||
final int totalAlbums;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _FetchingProgressDialog({
|
||||
required this.totalAlbums,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
// Static method to update progress from outside
|
||||
static void updateProgress(BuildContext context, int current, int total) {
|
||||
final state = context.findAncestorStateOfType<_FetchingProgressDialogState>();
|
||||
state?._updateProgress(current, total);
|
||||
}
|
||||
|
||||
@override
|
||||
State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState();
|
||||
}
|
||||
|
||||
class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
|
||||
int _current = 0;
|
||||
int _total = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_total = widget.totalAlbums;
|
||||
}
|
||||
|
||||
void _updateProgress(int current, int total) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_current = current;
|
||||
_total = total;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final progress = _total > 0 ? _current / _total : 0.0;
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
strokeWidth: 4,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Icon(Icons.library_music, color: colorScheme.primary, size: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.discographyFetchingTracks,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.discographyFetchingAlbum(_current, _total),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Progress bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
@@ -29,15 +31,71 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_extractDominantColor();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
|
||||
|
||||
// Check cache first (instant)
|
||||
final cached = PaletteService.instance.getCached(widget.coverUrl);
|
||||
if (cached != null) {
|
||||
if (mounted && cached != _dominantColor) {
|
||||
setState(() {
|
||||
_dominantColor = cached;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract in isolate (non-blocking)
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null && color != _dominantColor) {
|
||||
setState(() {
|
||||
_dominantColor = color;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Get tracks for this album from history provider (reactive)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||
return allItems.where((item) {
|
||||
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
: item.artistName;
|
||||
// Use lowercase for case-insensitive matching
|
||||
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
@@ -45,6 +103,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
final discMap = <int, List<DownloadHistoryItem>>{};
|
||||
for (final track in tracks) {
|
||||
final discNumber = track.discNumber ?? 1;
|
||||
discMap.putIfAbsent(discNumber, () => []).add(track);
|
||||
}
|
||||
return discMap;
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
@@ -145,6 +214,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
_precacheCover(item.coverUrl);
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
@@ -153,6 +223,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
));
|
||||
}
|
||||
|
||||
void _precacheCover(String? url) {
|
||||
if (url == null || url.isEmpty) return;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -161,11 +242,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final tracks = _getAlbumTracks(allHistoryItems);
|
||||
|
||||
if (tracks.length < 2) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
// Show empty state if no tracks found
|
||||
if (tracks.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.albumName),
|
||||
),
|
||||
body: Center(
|
||||
child: Text('No tracks found for this album'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final validIds = tracks.map((t) => t.id).toSet();
|
||||
@@ -187,6 +273,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme, tracks),
|
||||
@@ -211,69 +298,99 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
colorScheme.surface.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.albumName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cover image centered - fade out when collapsing
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
@@ -388,16 +505,83 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
final discMap = _groupTracksByDisc(tracks);
|
||||
|
||||
if (discMap.length <= 1) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final discNumbers = discMap.keys.toList()..sort();
|
||||
final List<Widget> children = [];
|
||||
|
||||
for (final discNumber in discNumbers) {
|
||||
final discTracks = discMap[discNumber];
|
||||
if (discTracks == null || discTracks.isEmpty) continue;
|
||||
|
||||
// Add disc separator
|
||||
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
|
||||
|
||||
// Add tracks for this disc
|
||||
for (final track in discTracks) {
|
||||
children.add(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate(children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
@@ -45,16 +46,15 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadTrack(int index) {
|
||||
final trackState = ref.read(trackProvider);
|
||||
if (index >= 0 && index < trackState.tracks.length) {
|
||||
final track = trackState.tracks[index];
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
||||
);
|
||||
}
|
||||
void _downloadTrack(Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(
|
||||
track,
|
||||
settings.defaultService,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadAll() {
|
||||
@@ -88,8 +88,10 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final queuedCount =
|
||||
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = trackState.tracks;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -145,13 +147,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
if (trackState.albumName != null || trackState.playlistName != null)
|
||||
_buildHeader(trackState, colorScheme),
|
||||
|
||||
if (trackState.tracks.length > 1)
|
||||
if (tracks.length > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _downloadAll,
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${trackState.tracks.length})'),
|
||||
label: Text('Download All (${tracks.length})'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
@@ -159,11 +161,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: trackState.tracks.isEmpty
|
||||
child: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: trackState.tracks.length,
|
||||
itemBuilder: (context, index) => _buildTrackTile(index, colorScheme),
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -179,13 +182,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState.queuedCount > 0,
|
||||
label: Text('${queueState.queuedCount}'),
|
||||
isLabelVisible: queuedCount > 0,
|
||||
label: Text('$queuedCount'),
|
||||
child: const Icon(Icons.queue_music_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState.queuedCount > 0,
|
||||
label: Text('${queueState.queuedCount}'),
|
||||
isLabelVisible: queuedCount > 0,
|
||||
label: Text('$queuedCount'),
|
||||
child: const Icon(Icons.queue_music),
|
||||
),
|
||||
label: 'Queue',
|
||||
@@ -210,11 +213,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
if (state.coverUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: state.coverUrl!,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
@@ -259,8 +263,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||
final track = ref.watch(trackProvider).tracks[index];
|
||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||
final isCollection = track.isCollection;
|
||||
|
||||
String subtitleText;
|
||||
@@ -281,11 +284,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
@@ -315,7 +319,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
|
||||
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle back press with double-tap to exit
|
||||
void _handleBackPress() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
@@ -174,9 +173,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
// Determine if we can pop (for predictive back animation)
|
||||
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
|
||||
final canPop = _currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
@@ -250,8 +246,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) {
|
||||
// System handled the pop - this means predictive back completed
|
||||
// We need to handle double-tap to exit here
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -9,8 +11,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Playlist detail screen with Material Expressive 3 design
|
||||
class PlaylistScreen extends ConsumerWidget {
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
final List<Track> tracks;
|
||||
@@ -23,16 +24,55 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
||||
}
|
||||
|
||||
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_extractDominantColor();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null) return;
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null) {
|
||||
setState(() => _dominantColor = color);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, ref, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
_buildTrackList(context, ref, colorScheme),
|
||||
_buildTrackList(context, colorScheme),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
@@ -40,59 +80,115 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (coverUrl != null)
|
||||
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.playlistName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cover image centered - fade out when collapsing
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -105,7 +201,7 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
@@ -115,16 +211,19 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context, ref),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -149,25 +248,25 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
final track = widget.tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _PlaylistTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, ref, track),
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
childCount: widget.tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
@@ -186,22 +285,22 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context, WidgetRef ref) {
|
||||
if (tracks.isEmpty) return;
|
||||
void _downloadAll(BuildContext context) {
|
||||
if (widget.tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: playlistName,
|
||||
trackName: '${widget.tracks.length} tracks',
|
||||
artistName: widget.playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,9 +316,9 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
final queueItem = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
|
||||
);
|
||||
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
@@ -241,8 +340,8 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
|
||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
@@ -10,20 +11,20 @@ class QueueScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.queueTitle),
|
||||
actions: [
|
||||
if (queueState.items.isNotEmpty)
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||
tooltip: context.l10n.queueClearCompleted,
|
||||
),
|
||||
if (queueState.items.isNotEmpty)
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
onPressed: () => _showClearAllDialog(context, ref),
|
||||
@@ -31,11 +32,12 @@ class QueueScreen extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: queueState.items.isEmpty
|
||||
body: items.isEmpty
|
||||
? _buildEmptyState(context, colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: queueState.items.length,
|
||||
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildQueueItem(context, ref, items[index], colorScheme),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -74,11 +76,12 @@ class QueueScreen extends ConsumerWidget {
|
||||
leading: item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -12,13 +15,13 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
|
||||
/// Grouped album data for history display
|
||||
class _GroupedAlbum {
|
||||
final String albumName;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
final List<DownloadHistoryItem> tracks;
|
||||
final DateTime latestDownload;
|
||||
final String searchKey;
|
||||
|
||||
_GroupedAlbum({
|
||||
required this.albumName,
|
||||
@@ -26,11 +29,61 @@ class _GroupedAlbum {
|
||||
this.coverUrl,
|
||||
required this.tracks,
|
||||
required this.latestDownload,
|
||||
});
|
||||
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
|
||||
String get key => '$albumName|$artistName';
|
||||
}
|
||||
|
||||
class _HistoryStats {
|
||||
final Map<String, int> albumCounts;
|
||||
final List<_GroupedAlbum> groupedAlbums;
|
||||
final int albumCount;
|
||||
final int singleTracks;
|
||||
|
||||
const _HistoryStats({
|
||||
required this.albumCounts,
|
||||
required this.groupedAlbums,
|
||||
required this.albumCount,
|
||||
required this.singleTracks,
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, List<String>> _filterHistoryInIsolate(
|
||||
Map<String, Object> payload,
|
||||
) {
|
||||
final entries = (payload['entries'] as List).cast<List>();
|
||||
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
||||
final query = (payload['query'] as String?) ?? '';
|
||||
|
||||
final allIds = <String>[];
|
||||
final albumIds = <String>[];
|
||||
final singleIds = <String>[];
|
||||
|
||||
for (final entry in entries) {
|
||||
final id = entry[0] as String;
|
||||
final albumKey = entry[1] as String;
|
||||
final searchKey = entry[2] as String;
|
||||
|
||||
if (query.isNotEmpty && !searchKey.contains(query)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allIds.add(id);
|
||||
final count = albumCounts[albumKey] ?? 0;
|
||||
if (count > 1) {
|
||||
albumIds.add(id);
|
||||
} else if (count == 1) {
|
||||
singleIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'all': allIds,
|
||||
'albums': albumIds,
|
||||
'singles': singleIds,
|
||||
};
|
||||
}
|
||||
|
||||
class QueueTab extends ConsumerStatefulWidget {
|
||||
final PageController? parentPageController;
|
||||
final int parentPageIndex;
|
||||
@@ -59,6 +112,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
||||
bool _isPageControllerInitialized = false;
|
||||
|
||||
// Search functionality
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String _searchQuery = '';
|
||||
Timer? _searchDebounce;
|
||||
List<DownloadHistoryItem>? _historyItemsCache;
|
||||
_HistoryStats? _historyStatsCache;
|
||||
final Map<String, String> _searchIndexCache = {};
|
||||
Map<String, DownloadHistoryItem> _historyItemsById = {};
|
||||
List<List<String>> _historyFilterEntries = const [];
|
||||
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
||||
List<DownloadHistoryItem>? _filterItemsCache;
|
||||
String _filterQueryCache = '';
|
||||
bool _filterRefreshScheduled = false;
|
||||
bool _isFilteringHistory = false;
|
||||
int _filterRequestId = 0;
|
||||
static const int _filterIsolateThreshold = 800;
|
||||
|
||||
|
||||
|
||||
@override
|
||||
@@ -74,12 +145,178 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_filterPageController = PageController(initialPage: initialPage);
|
||||
}
|
||||
|
||||
@override
|
||||
@override
|
||||
void dispose() {
|
||||
_filterPageController?.dispose();
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_searchDebounce?.cancel();
|
||||
final normalized = value.trim().toLowerCase();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 180), () {
|
||||
if (!mounted || _searchQuery == normalized) return;
|
||||
setState(() => _searchQuery = normalized);
|
||||
_requestFilterRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_searchDebounce?.cancel();
|
||||
if (_searchQuery.isEmpty) return;
|
||||
setState(() => _searchQuery = '');
|
||||
_requestFilterRefresh();
|
||||
}
|
||||
|
||||
void _ensureHistoryCaches(List<DownloadHistoryItem> items) {
|
||||
if (identical(items, _historyItemsCache)) return;
|
||||
_historyItemsCache = items;
|
||||
_historyStatsCache = _buildHistoryStats(items);
|
||||
_searchIndexCache
|
||||
..clear()
|
||||
..addEntries(
|
||||
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
|
||||
);
|
||||
_historyItemsById = {for (final item in items) item.id: item};
|
||||
_historyFilterEntries = List<List<String>>.generate(
|
||||
items.length,
|
||||
(index) {
|
||||
final item = items[index];
|
||||
final searchKey =
|
||||
_searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
final albumKey =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return [item.id, albumKey, searchKey];
|
||||
},
|
||||
growable: false,
|
||||
);
|
||||
_requestFilterRefresh();
|
||||
}
|
||||
|
||||
String _buildSearchKey(DownloadHistoryItem item) {
|
||||
return '${item.trackName} ${item.artistName} ${item.albumName}'
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
bool _isFilterCacheValid(List<DownloadHistoryItem> items, String query) {
|
||||
return identical(items, _filterItemsCache) && query == _filterQueryCache;
|
||||
}
|
||||
|
||||
void _requestFilterRefresh() {
|
||||
if (_filterRefreshScheduled) return;
|
||||
_filterRefreshScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_filterRefreshScheduled = false;
|
||||
if (!mounted) return;
|
||||
_scheduleHistoryFilterUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleHistoryFilterUpdate() {
|
||||
final items = _historyItemsCache;
|
||||
if (items == null) return;
|
||||
final query = _searchQuery;
|
||||
if (_isFilterCacheValid(items, query)) return;
|
||||
|
||||
final albumCounts =
|
||||
_historyStatsCache?.albumCounts ?? const <String, int>{};
|
||||
if (items.isEmpty) {
|
||||
setState(() {
|
||||
_filteredHistoryCache = const {};
|
||||
_filterItemsCache = items;
|
||||
_filterQueryCache = query;
|
||||
_isFilteringHistory = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length <= _filterIsolateThreshold) {
|
||||
final filteredAll =
|
||||
_filterHistoryItems(items, 'all', albumCounts, query);
|
||||
final filteredAlbums =
|
||||
_filterHistoryItems(items, 'albums', albumCounts, query);
|
||||
final filteredSingles =
|
||||
_filterHistoryItems(items, 'singles', albumCounts, query);
|
||||
setState(() {
|
||||
_filteredHistoryCache = {
|
||||
'all': filteredAll,
|
||||
'albums': filteredAlbums,
|
||||
'singles': filteredSingles,
|
||||
};
|
||||
_filterItemsCache = items;
|
||||
_filterQueryCache = query;
|
||||
_isFilteringHistory = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isFilteringHistory) {
|
||||
setState(() => _isFilteringHistory = true);
|
||||
}
|
||||
|
||||
final requestId = ++_filterRequestId;
|
||||
final payload = <String, Object>{
|
||||
'entries': _historyFilterEntries,
|
||||
'albumCounts': albumCounts,
|
||||
'query': query,
|
||||
};
|
||||
|
||||
compute(_filterHistoryInIsolate, payload).then((result) {
|
||||
if (!mounted || requestId != _filterRequestId) return;
|
||||
final itemsById = _historyItemsById;
|
||||
final filtered = <String, List<DownloadHistoryItem>>{};
|
||||
for (final entry in result.entries) {
|
||||
filtered[entry.key] = entry.value
|
||||
.map((id) => itemsById[id])
|
||||
.whereType<DownloadHistoryItem>()
|
||||
.toList(growable: false);
|
||||
}
|
||||
setState(() {
|
||||
_filteredHistoryCache = filtered;
|
||||
_filterItemsCache = items;
|
||||
_filterQueryCache = query;
|
||||
_isFilteringHistory = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
List<DownloadHistoryItem> _resolveHistoryItems({
|
||||
required String filterMode,
|
||||
required List<DownloadHistoryItem> allHistoryItems,
|
||||
required Map<String, int> albumCounts,
|
||||
}) {
|
||||
final query = _searchQuery;
|
||||
if (_isFilterCacheValid(allHistoryItems, query)) {
|
||||
final cached = _filteredHistoryCache[filterMode];
|
||||
if (cached != null) return cached;
|
||||
}
|
||||
if (allHistoryItems.isEmpty) return const [];
|
||||
if (query.isEmpty && filterMode == 'all') return allHistoryItems;
|
||||
if (allHistoryItems.length <= _filterIsolateThreshold) {
|
||||
return _filterHistoryItems(
|
||||
allHistoryItems,
|
||||
filterMode,
|
||||
albumCounts,
|
||||
query,
|
||||
);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool _shouldShowFilteringIndicator({
|
||||
required List<DownloadHistoryItem> allHistoryItems,
|
||||
required String filterMode,
|
||||
}) {
|
||||
if (allHistoryItems.isEmpty) return false;
|
||||
if (_searchQuery.isEmpty && filterMode == 'all') return false;
|
||||
if (allHistoryItems.length <= _filterIsolateThreshold) return false;
|
||||
return !_isFilterCacheValid(allHistoryItems, _searchQuery) ||
|
||||
_isFilteringHistory;
|
||||
}
|
||||
|
||||
void _onFilterPageChanged(int index) {
|
||||
final filterMode = _filterModes[index];
|
||||
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
|
||||
@@ -93,7 +330,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Enter selection mode with initial item
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
@@ -110,7 +346,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle item selection
|
||||
void _toggleSelection(String itemId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(itemId)) {
|
||||
@@ -131,7 +366,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete selected items
|
||||
Future<void> _deleteSelected() async {
|
||||
final count = _selectedIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -234,6 +468,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
void _precacheCover(String? url) {
|
||||
if (url == null || url.isEmpty) return;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadItem item) {
|
||||
final historyItem = ref
|
||||
.read(downloadHistoryProvider)
|
||||
@@ -252,6 +497,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
);
|
||||
|
||||
_precacheCover(historyItem.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
@@ -262,10 +509,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
||||
_precacheCover(item.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
@@ -276,121 +525,105 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
/// Filter history items based on current filter mode
|
||||
/// Album = track yang albumnya punya >1 track di history
|
||||
/// Single = track yang albumnya cuma 1 track di history
|
||||
List<DownloadHistoryItem> _filterHistoryItems(
|
||||
List<DownloadHistoryItem> _filterHistoryItems(
|
||||
List<DownloadHistoryItem> items,
|
||||
String filterMode,
|
||||
) {
|
||||
if (filterMode == 'all') return items;
|
||||
|
||||
final albumCounts = <String, int>{};
|
||||
for (final item in items) {
|
||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
|
||||
Map<String, int> albumCounts, [
|
||||
String searchQuery = '',
|
||||
]) {
|
||||
// First apply search filter
|
||||
var filteredItems = items;
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery;
|
||||
filteredItems = items.where((item) {
|
||||
final searchKey =
|
||||
_searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
if (!_searchIndexCache.containsKey(item.id)) {
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
return searchKey.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
switch (filterMode) {
|
||||
// Then apply filter mode
|
||||
if (filterMode == 'all') return filteredItems;
|
||||
|
||||
switch (filterMode) {
|
||||
case 'albums':
|
||||
return items.where((item) {
|
||||
return filteredItems.where((item) {
|
||||
final key =
|
||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return (albumCounts[key] ?? 0) > 1;
|
||||
}).toList();
|
||||
case 'singles':
|
||||
return items.where((item) {
|
||||
return filteredItems.where((item) {
|
||||
final key =
|
||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return (albumCounts[key] ?? 0) == 1;
|
||||
}).toList();
|
||||
default:
|
||||
return items;
|
||||
return filteredItems;
|
||||
}
|
||||
}
|
||||
|
||||
/// Count albums vs singles for filter chips
|
||||
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
|
||||
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
|
||||
final albumCounts = <String, int>{};
|
||||
final albumMap = <String, List<DownloadHistoryItem>>{};
|
||||
for (final item in items) {
|
||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
// Use lowercase key for case-insensitive grouping
|
||||
final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
|
||||
albumMap.putIfAbsent(key, () => []).add(item);
|
||||
}
|
||||
|
||||
int albumTracks = 0;
|
||||
int singleTracks = 0;
|
||||
|
||||
for (final item in items) {
|
||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
if ((albumCounts[key] ?? 0) > 1) {
|
||||
albumTracks++;
|
||||
} else {
|
||||
final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
if ((albumCounts[key] ?? 0) <= 1) {
|
||||
singleTracks++;
|
||||
}
|
||||
}
|
||||
|
||||
return {'albums': albumTracks, 'singles': singleTracks};
|
||||
}
|
||||
final groupedAlbums = <_GroupedAlbum>[];
|
||||
albumMap.forEach((_, tracks) {
|
||||
if (tracks.length <= 1) return;
|
||||
tracks.sort((a, b) {
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
return aNum.compareTo(bNum);
|
||||
});
|
||||
|
||||
/// Group history items by album (for Albums filter view)
|
||||
List<_GroupedAlbum> _groupByAlbum(List<DownloadHistoryItem> items) {
|
||||
final albumMap = <String, List<DownloadHistoryItem>>{};
|
||||
|
||||
for (final item in items) {
|
||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
albumMap.putIfAbsent(key, () => []).add(item);
|
||||
}
|
||||
|
||||
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map(
|
||||
(e) {
|
||||
final tracks = e.value;
|
||||
tracks.sort((a, b) {
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
return aNum.compareTo(bNum);
|
||||
});
|
||||
|
||||
return _GroupedAlbum(
|
||||
albumName: tracks.first.albumName,
|
||||
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
|
||||
coverUrl: tracks.first.coverUrl,
|
||||
tracks: tracks,
|
||||
latestDownload: tracks
|
||||
.map((t) => t.downloadedAt)
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b),
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
groupedAlbums.add(_GroupedAlbum(
|
||||
albumName: tracks.first.albumName,
|
||||
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
|
||||
coverUrl: tracks.first.coverUrl,
|
||||
tracks: tracks,
|
||||
latestDownload: tracks
|
||||
.map((t) => t.downloadedAt)
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b),
|
||||
));
|
||||
});
|
||||
|
||||
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
|
||||
|
||||
return groupedAlbums;
|
||||
}
|
||||
|
||||
/// Count unique albums (for filter chip badge)
|
||||
int _countUniqueAlbums(List<DownloadHistoryItem> items) {
|
||||
final albumKeys = <String>{};
|
||||
for (final item in items) {
|
||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
albumKeys.add(key);
|
||||
int albumCount = 0;
|
||||
for (final count in albumCounts.values) {
|
||||
if (count > 1) albumCount++;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
for (final key in albumKeys) {
|
||||
final trackCount = items
|
||||
.where(
|
||||
(i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key,
|
||||
)
|
||||
.length;
|
||||
if (trackCount > 1) count++;
|
||||
}
|
||||
return count;
|
||||
return _HistoryStats(
|
||||
albumCounts: albumCounts,
|
||||
groupedAlbums: groupedAlbums,
|
||||
albumCount: albumCount,
|
||||
singleTracks: singleTracks,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
||||
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
@@ -405,27 +638,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_initializePageController();
|
||||
|
||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
final isProcessing = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.isProcessing),
|
||||
);
|
||||
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||
final queuedCount = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.queuedCount),
|
||||
);
|
||||
final completedCount = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.completedCount),
|
||||
);
|
||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
final allHistoryItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
);
|
||||
_ensureHistoryCaches(allHistoryItems);
|
||||
final historyViewMode = ref.watch(
|
||||
settingsProvider.select((s) => s.historyViewMode),
|
||||
);
|
||||
@@ -435,11 +659,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
final groupedAlbums = _groupByAlbum(allHistoryItems);
|
||||
|
||||
final counts = _countAlbumsAndSingles(allHistoryItems);
|
||||
final albumCount = _countUniqueAlbums(allHistoryItems);
|
||||
final singleCount = counts['singles'] ?? 0;
|
||||
final historyStats =
|
||||
_historyStatsCache ?? _buildHistoryStats(allHistoryItems);
|
||||
final groupedAlbums = historyStats.groupedAlbums;
|
||||
final albumCount = historyStats.albumCount;
|
||||
final singleCount = historyStats.singleTracks;
|
||||
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
@@ -491,68 +715,88 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if ((isProcessing || queuedCount > 0) &&
|
||||
(queueItems.length > 1 || isPaused))
|
||||
// Search bar - always at top
|
||||
if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isPaused
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
isPaused ? Icons.pause : Icons.downloading,
|
||||
color: isPaused
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: false,
|
||||
canRequestFocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.historySearchHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_clearSearch();
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isPaused
|
||||
? 'Paused'
|
||||
: '$completedCount/${queueItems.length}',
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
width: 1.5,
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.togglePause(),
|
||||
child: Text(isPaused ? 'Resume' : 'Pause'),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 2.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
onTapOutside: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'Downloading (${queueItems.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Downloading (${queueItems.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildPauseResumeButton(context, ref, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverList(
|
||||
@@ -562,7 +806,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
key: ValueKey(item.id),
|
||||
child: _buildQueueItem(context, item, colorScheme),
|
||||
);
|
||||
}, childCount: queueItems.length),
|
||||
}, childCount: queueItems.length),
|
||||
),
|
||||
|
||||
if (allHistoryItems.isNotEmpty)
|
||||
@@ -666,39 +910,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
return false;
|
||||
},
|
||||
child: PageView(
|
||||
child: PageView.builder(
|
||||
controller: _filterPageController!,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
onPageChanged: _onFilterPageChanged,
|
||||
children: [
|
||||
_buildFilterContent(
|
||||
itemCount: _filterModes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filterMode = _filterModes[index];
|
||||
return _buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: 'all',
|
||||
filterMode: filterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
historyViewMode: historyViewMode,
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
),
|
||||
_buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: 'albums',
|
||||
allHistoryItems: allHistoryItems,
|
||||
historyViewMode: historyViewMode,
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
),
|
||||
_buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
filterMode: 'singles',
|
||||
allHistoryItems: allHistoryItems,
|
||||
historyViewMode: historyViewMode,
|
||||
queueItems: queueItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
),
|
||||
],
|
||||
albumCounts: historyStats.albumCounts,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -710,10 +939,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(
|
||||
child: _buildSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
_filterHistoryItems(allHistoryItems, historyFilterMode),
|
||||
_resolveHistoryItems(
|
||||
filterMode: historyFilterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
albumCounts: historyStats.albumCounts,
|
||||
),
|
||||
bottomPadding,
|
||||
),
|
||||
),
|
||||
@@ -722,7 +955,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build content for each filter tab
|
||||
Widget _buildFilterContent({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -731,8 +963,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required String historyViewMode,
|
||||
required List<DownloadItem> queueItems,
|
||||
required List<_GroupedAlbum> groupedAlbums,
|
||||
required Map<String, int> albumCounts,
|
||||
}) {
|
||||
final historyItems = _filterHistoryItems(allHistoryItems, filterMode);
|
||||
final historyItems = _resolveHistoryItems(
|
||||
filterMode: filterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
albumCounts: albumCounts,
|
||||
);
|
||||
final showFilteringIndicator = _shouldShowFilteringIndicator(
|
||||
allHistoryItems: allHistoryItems,
|
||||
filterMode: filterMode,
|
||||
);
|
||||
|
||||
// Filter grouped albums based on search query
|
||||
final searchQuery = _searchQuery;
|
||||
final filteredGroupedAlbums = searchQuery.isEmpty
|
||||
? groupedAlbums
|
||||
: groupedAlbums
|
||||
.where((album) => album.searchKey.contains(searchQuery))
|
||||
.toList();
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -766,14 +1015,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (groupedAlbums.isNotEmpty &&
|
||||
if (filteredGroupedAlbums.isNotEmpty &&
|
||||
queueItems.isEmpty &&
|
||||
filterMode == 'albums')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}',
|
||||
'${filteredGroupedAlbums.length} ${filteredGroupedAlbums.length == 1 ? 'album' : 'albums'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -794,7 +1043,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
|
||||
if (showFilteringIndicator)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Filtering...',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverGrid(
|
||||
@@ -806,12 +1081,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final album = groupedAlbums[index];
|
||||
final album = filteredGroupedAlbums[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(album.key),
|
||||
child: _buildAlbumGridItem(context, album, colorScheme),
|
||||
);
|
||||
}, childCount: groupedAlbums.length),
|
||||
}, childCount: filteredGroupedAlbums.length),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -857,9 +1132,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}, childCount: historyItems.length ),
|
||||
),
|
||||
|
||||
if (queueItems.isEmpty &&
|
||||
if (queueItems.isEmpty &&
|
||||
historyItems.isEmpty &&
|
||||
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
||||
(filterMode != 'albums' || filteredGroupedAlbums.isEmpty) &&
|
||||
!showFilteringIndicator)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _buildEmptyState(
|
||||
@@ -876,6 +1152,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPauseResumeButton(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||
|
||||
return TextButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(downloadQueueProvider.notifier).togglePause();
|
||||
},
|
||||
icon: Icon(
|
||||
isPaused ? Icons.play_arrow : Icons.pause,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
isPaused ? context.l10n.actionResume : context.l10n.actionPause,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -926,7 +1227,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build album grid item for grouped albums view
|
||||
Widget _buildAlbumGridItem(
|
||||
BuildContext context,
|
||||
_GroupedAlbum album,
|
||||
@@ -943,13 +1243,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
? CachedNetworkImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 300,
|
||||
memCacheHeight: 300,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
@@ -1245,13 +1546,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
@@ -1404,11 +1706,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: item.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 200,
|
||||
memCacheHeight: 200,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
@@ -1613,13 +1916,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
@@ -1736,7 +2040,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter chip widget for history filtering
|
||||
class _FilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
@@ -43,22 +45,22 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadTrack(int index) {
|
||||
final trackState = ref.read(trackProvider);
|
||||
if (index >= 0 && index < trackState.tracks.length) {
|
||||
final track = trackState.tracks[index];
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
||||
);
|
||||
}
|
||||
void _downloadTrack(Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(
|
||||
track,
|
||||
settings.defaultService,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = trackState.tracks;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -95,11 +97,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: trackState.tracks.isEmpty
|
||||
child: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: trackState.tracks.length,
|
||||
itemBuilder: (context, index) => _buildTrackTile(index, colorScheme),
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -129,17 +132,17 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||
final track = ref.watch(trackProvider).tracks[index];
|
||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||
return ListTile(
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
@@ -173,9 +176,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.download, color: colorScheme.primary),
|
||||
onPressed: () => _downloadTrack(index),
|
||||
onPressed: () => _downloadTrack(track),
|
||||
),
|
||||
onTap: () => _downloadTrack(index),
|
||||
onTap: () => _downloadTrack(track),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
@@ -14,7 +15,7 @@ class AboutPage extends StatelessWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -86,6 +87,13 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: _TranslatorsSection(),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
|
||||
),
|
||||
@@ -149,7 +157,7 @@ class AboutPage extends StatelessWidget {
|
||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.lightbulb_outline,
|
||||
title: context.l10n.aboutFeatureRequest,
|
||||
subtitle: context.l10n.aboutFeatureRequestSubtitle,
|
||||
@@ -160,6 +168,30 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.telegram,
|
||||
title: context.l10n.aboutTelegramChannel,
|
||||
subtitle: context.l10n.aboutTelegramChannelSubtitle,
|
||||
onTap: () => _launchUrl('https://t.me/spotiflac'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.forum_outlined,
|
||||
title: context.l10n.aboutTelegramChat,
|
||||
subtitle: context.l10n.aboutTelegramChatSubtitle,
|
||||
onTap: () => _launchUrl('https://t.me/spotiflacchat'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||
),
|
||||
@@ -245,9 +277,9 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
child: Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||
color: colorScheme.onPrimary,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
@@ -326,11 +358,12 @@ class _ContributorItem extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: 'https://github.com/$githubUsername.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -395,7 +428,140 @@ class _ContributorItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings item with 40x40 icon area to align with contributor avatars
|
||||
/// Translator data model
|
||||
class _Translator {
|
||||
final String name;
|
||||
final String crowdinUsername;
|
||||
final String language;
|
||||
final String flag;
|
||||
|
||||
const _Translator({
|
||||
required this.name,
|
||||
required this.crowdinUsername,
|
||||
required this.language,
|
||||
required this.flag,
|
||||
});
|
||||
}
|
||||
|
||||
/// Translators section with compact chip-style layout
|
||||
class _TranslatorsSection extends StatelessWidget {
|
||||
const _TranslatorsSection();
|
||||
|
||||
static const List<_Translator> _translators = [
|
||||
_Translator(
|
||||
name: 'Pedro Marcondes',
|
||||
crowdinUsername: 'justapedro',
|
||||
language: 'Portuguese',
|
||||
flag: '🇵🇹',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Credits 125',
|
||||
crowdinUsername: 'credits125',
|
||||
language: 'Spanish',
|
||||
flag: '🇪🇸',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Владислав',
|
||||
crowdinUsername: 'odinokiy_kot',
|
||||
language: 'Russian',
|
||||
flag: '🇷🇺',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Max',
|
||||
crowdinUsername: 'amonoman',
|
||||
language: 'German',
|
||||
flag: '🇩🇪',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _translators.map((translator) => _TranslatorChip(
|
||||
translator: translator,
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual translator chip
|
||||
class _TranslatorChip extends StatelessWidget {
|
||||
final _Translator translator;
|
||||
|
||||
const _TranslatorChip({required this.translator});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
onTap: () => _launchCrowdin(translator.crowdinUsername),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
translator.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
translator.flag,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchCrowdin(String username) async {
|
||||
final uri = Uri.parse('https://crowdin.com/profile/$username');
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
}
|
||||
}
|
||||
|
||||
class _AboutSettingsItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
||||
@@ -17,7 +17,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -161,7 +161,7 @@ class _ThemePreviewCard extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme
|
||||
.surfaceContainerHighest, // Background similar to reference
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
@@ -203,7 +203,7 @@ class _ThemePreviewCard extends StatelessWidget {
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 12, // Reduced from 20 for performance
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
@@ -694,20 +694,24 @@ class _LanguageSelector extends StatelessWidget {
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
static const _allLanguages = [
|
||||
static const _allLanguages = [
|
||||
('system', 'System Default', Icons.phone_android),
|
||||
('en', 'English', Icons.language),
|
||||
('id', 'Bahasa Indonesia', Icons.language),
|
||||
('de', 'Deutsch', Icons.language),
|
||||
('es', 'Español', Icons.language),
|
||||
('es_ES', 'Español (España)', Icons.language),
|
||||
('fr', 'Français', Icons.language),
|
||||
('hi', 'हिन्दी', Icons.language),
|
||||
('ja', '日本語', Icons.language),
|
||||
('ko', '한국어', Icons.language),
|
||||
('nl', 'Nederlands', Icons.language),
|
||||
('pt', 'Português', Icons.language),
|
||||
('pt_PT', 'Português (Brasil)', Icons.language),
|
||||
('ru', 'Русский', Icons.language),
|
||||
('tr', 'Türkçe', Icons.language),
|
||||
('zh', '简体中文', Icons.language),
|
||||
('zh_CN', '简体中文 (中国)', Icons.language),
|
||||
('zh_TW', '繁體中文', Icons.language),
|
||||
];
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -99,6 +99,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setAskQualityBeforeDownload(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.audiotrack,
|
||||
title: context.l10n.enableMp3Option,
|
||||
subtitle: settings.enableMp3Option
|
||||
? context.l10n.enableMp3OptionSubtitleOn
|
||||
: context.l10n.enableMp3OptionSubtitleOff,
|
||||
value: settings.enableMp3Option,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEnableMp3Option(value),
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
title: context.l10n.qualityFlacLossless,
|
||||
@@ -123,8 +134,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||
showDivider: false,
|
||||
showDivider: settings.enableMp3Option,
|
||||
),
|
||||
if (settings.enableMp3Option)
|
||||
_QualityOption(
|
||||
title: context.l10n.qualityMp3,
|
||||
subtitle: context.l10n.qualityMp3Subtitle,
|
||||
isSelected: settings.audioQuality == 'MP3',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('MP3'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
if (!isBuiltInService) ...[
|
||||
Padding(
|
||||
@@ -148,14 +169,35 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
@@ -234,6 +276,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
return 'Albums/Artist/[Year] Album/';
|
||||
case 'year_album':
|
||||
return 'Albums/[Year] Album/';
|
||||
case 'artist_album_singles':
|
||||
return 'Artist/Album/ + Artist/Singles/';
|
||||
default:
|
||||
return 'Albums/Artist/Album Name/';
|
||||
}
|
||||
@@ -286,6 +330,16 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistAlbumSingles),
|
||||
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
|
||||
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -585,6 +639,89 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _getLyricsModeLabel(BuildContext context, String mode) {
|
||||
switch (mode) {
|
||||
case 'external':
|
||||
return context.l10n.lyricsModeExternal;
|
||||
case 'both':
|
||||
return context.l10n.lyricsModeBoth;
|
||||
default:
|
||||
return context.l10n.lyricsModeEmbed;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLyricsModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.lyricsMode,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.lyricsModeDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: Text(context.l10n.lyricsModeEmbed),
|
||||
subtitle: Text(context.l10n.lyricsModeEmbedSubtitle),
|
||||
trailing: current == 'embed' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLyricsMode('embed');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: Text(context.l10n.lyricsModeExternal),
|
||||
subtitle: Text(context.l10n.lyricsModeExternalSubtitle),
|
||||
trailing: current == 'external' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLyricsMode('external');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.library_music_outlined),
|
||||
title: Text(context.l10n.lyricsModeBoth),
|
||||
subtitle: Text(context.l10n.lyricsModeBothSubtitle),
|
||||
trailing: current == 'both' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLyricsMode('both');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFolderOrganizationPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
||||
@@ -342,6 +343,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
value: _settings[setting.key] ?? setting.defaultValue,
|
||||
showDivider: index < extension.settings.length - 1,
|
||||
onChanged: (value) => _updateSetting(setting.key, value),
|
||||
extensionId: widget.extensionId,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -587,41 +589,62 @@ class _PermissionItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingItem extends StatelessWidget {
|
||||
class _SettingItem extends StatefulWidget {
|
||||
final ExtensionSetting setting;
|
||||
final dynamic value;
|
||||
final bool showDivider;
|
||||
final ValueChanged<dynamic> onChanged;
|
||||
final String extensionId;
|
||||
|
||||
const _SettingItem({
|
||||
required this.setting,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.extensionId,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SettingItem> createState() => _SettingItemState();
|
||||
}
|
||||
|
||||
class _SettingItemState extends State<_SettingItem> {
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
Widget trailing;
|
||||
switch (setting.type) {
|
||||
switch (widget.setting.type) {
|
||||
case 'boolean':
|
||||
trailing = Switch(
|
||||
value: value as bool? ?? false,
|
||||
onChanged: onChanged,
|
||||
value: widget.value as bool? ?? false,
|
||||
onChanged: widget.onChanged,
|
||||
);
|
||||
break;
|
||||
case 'select':
|
||||
trailing = DropdownButton<String>(
|
||||
value: value as String?,
|
||||
items: setting.options?.map((opt) {
|
||||
value: widget.value as String?,
|
||||
items: widget.setting.options?.map((opt) {
|
||||
return DropdownMenuItem(value: opt, child: Text(opt));
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
onChanged: widget.onChanged,
|
||||
underline: const SizedBox(),
|
||||
);
|
||||
break;
|
||||
case 'button':
|
||||
trailing = _isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: () => _invokeAction(context),
|
||||
child: Text(widget.setting.label),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
trailing = Icon(
|
||||
Icons.chevron_right,
|
||||
@@ -629,11 +652,52 @@ class _SettingItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// For button type, show a different layout
|
||||
if (widget.setting.type == 'button') {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.setting.description != null) ...[
|
||||
Text(
|
||||
widget.setting.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: setting.type == 'string' || setting.type == 'number'
|
||||
onTap: widget.setting.type == 'string' || widget.setting.type == 'number'
|
||||
? () => _showEditDialog(context)
|
||||
: null,
|
||||
child: Padding(
|
||||
@@ -645,22 +709,22 @@ class _SettingItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
setting.label,
|
||||
widget.setting.label,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (setting.description != null) ...[
|
||||
if (widget.setting.description != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
setting.description!,
|
||||
widget.setting.description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (setting.type == 'string' || setting.type == 'number') ...[
|
||||
if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value?.toString() ?? 'Not set',
|
||||
widget.value?.toString() ?? 'Not set',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
@@ -674,7 +738,7 @@ class _SettingItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
if (widget.showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
@@ -686,21 +750,66 @@ class _SettingItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _invokeAction(BuildContext context) async {
|
||||
if (widget.setting.action == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No action defined for this button')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.invokeExtensionAction(
|
||||
widget.extensionId,
|
||||
widget.setting.action!,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
final success = result['success'] as bool? ?? false;
|
||||
if (!success) {
|
||||
final error = result['error'] as String? ?? 'Action failed';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
} else {
|
||||
final message = result['message'] as String?;
|
||||
if (message != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showEditDialog(BuildContext context) {
|
||||
final controller = TextEditingController(text: value?.toString() ?? '');
|
||||
final controller = TextEditingController(text: widget.value?.toString() ?? '');
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(setting.label),
|
||||
title: Text(widget.setting.label),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: setting.type == 'number'
|
||||
keyboardType: widget.setting.type == 'number'
|
||||
? TextInputType.number
|
||||
: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: setting.description ?? 'Enter value',
|
||||
hintText: widget.setting.description ?? 'Enter value',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
@@ -716,10 +825,10 @@ class _SettingItem extends StatelessWidget {
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final newValue = setting.type == 'number'
|
||||
final newValue = widget.setting.type == 'number'
|
||||
? num.tryParse(controller.text)
|
||||
: controller.text;
|
||||
onChanged(newValue);
|
||||
widget.onChanged(newValue);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.dialogSave),
|
||||
|
||||