diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..326c8513 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: zarzet diff --git a/CHANGELOG.md b/CHANGELOG.md index 57781320..05962b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,256 @@ # Changelog +## [3.1.3] - 2026-01-19 + +### Added + +- **External LRC Lyrics File Support**: Option to save lyrics as separate .lrc files for compatibility with external music players + - New "Lyrics Mode" setting in Settings > Download > Lyrics section + - Three modes available: + - **Embed in file** (default): Lyrics stored inside FLAC metadata + - **External .lrc file**: Save lyrics as separate .lrc file next to audio file + - **Both**: Embed and save external .lrc file + - Perfect for players like Samsung Music that prefer external .lrc files + - LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile) + - Works with all download services (Tidal, Qobuz, Amazon) + +- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists + - Quality picker now appears before adding CSV tracks to download queue + - Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3 + - Respects "Ask quality before download" setting - uses default quality if disabled + + - **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory + - Cover images no longer disappear when app is closed or device restarts + - Cache stored in `app_flutter/cover_cache/` directory (not cleared by system) + - Maximum 1000 images cached for up to 365 days + - Covers are cached when displayed in History, Home, Album, Artist, or any other screen + - New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management + +- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer + - New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre` + - Metadata fetched during `enrichTrack()` via Deezer album API + - Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` + - Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon) + +- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen + - Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model + - Metadata is stored in download history and persists across app restarts + - New localization strings: `trackGenre`, `trackLabel`, `trackCopyright` + +- **`utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings + - Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0` + - Useful for extensions that need to rotate User-Agents to avoid detection + +### Fixed + +- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES) + - App now correctly loads Portuguese and Spanish translations + - Updated Portuguese label to "Português (Brasil)" + +- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers + - Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization + - Added `VMMu sync.Mutex` to `LoadedExtension` struct + - Added mutex lock/unlock to ALL `ExtensionProviderWrapper` methods: + - `SearchTracks`, `GetTrack`, `GetAlbum`, `GetArtist` + - `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download` + - `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess` + - Prevents race conditions when rapidly switching between extension search providers + +- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal + - Now uses Tidal API's release date when `req.ReleaseDate` is empty + - Ensures release date is always embedded in downloaded files + +- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC + - Flutter now extracts extended metadata from Go backend response + - Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()` + - Tags correctly embedded during FFmpeg conversion + +- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC + - Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()` + - Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` + +### Extensions + +- **spotify-web Extension**: Updated to v1.7.0 + - Added `getMetadataFromDeezer()` function to fetch extended metadata: + - ISRC from track + - Label from album + - Copyright (generated as "YEAR LABEL") + - Genre from album genres + - Release date + - `enrichTrack()` now returns all extended metadata to Go backend + - Replaced all hardcoded User-Agent strings with `utils.randomUserAgent()` + +### Performance + +- **Faster App Startup**: Notification, Share Intent, and Cover Cache Manager initialization now run in parallel +- **Download Queue Polling**: Batched progress updates reduce rebuilds and list allocations during active downloads +- **Queue Item Updates**: Status/progress updates now skip no-op changes and update by index for fewer allocations +- **Directory Creation**: Download output folders are created once per path, reducing repeated I/O for albums/singles +- **Search Results Rendering**: Single-pass filtering avoids repeated `indexOf` calls for large result sets +- **Queue Lookups in UI**: O(1) lookup for queue status in Home/Album/Playlist/Artist track lists +- **History Filtering**: Album/single counts and grouping are computed once per build +- **Downloaded Album View**: Tracks are grouped by disc in one pass to reduce filtering overhead +- **Track Metadata Screen**: + - Palette extraction deferred until after transition; reduced sample size for smoother navigation + - File stat uses a single syscall and only triggers state updates on change + - Static regex/month table avoids repeated allocations + - Cover precached before opening metadata from history/queue/recents +- **Flutter Provider Optimizations**: + - Cache `SharedPreferences` instance in `DownloadHistoryNotifier` and `DownloadQueueNotifier` to avoid repeated `getInstance()` calls + - Precompile regex for folder name sanitization and year extraction (top-level `final`) + - Use `indexWhere` instead of `firstWhere` with placeholder object to reduce allocations in queue processing +- **Flutter UI Optimizations**: + - Selective `ref.watch()` for `downloadQueueProvider` (watch only `queuedCount` or `items` instead of entire state) + - Pass `Track` directly to `_buildTrackTile()` instead of index lookup inside builder + - Pass `historyItems` as parameter to `_buildRecentAccess()` to avoid `ref.read()` inside method +- **M4A Metadata Embedding**: Streaming implementation reduces memory usage for large files + - Uses `os.Open()` + `ReadAt` instead of `os.ReadFile()` (no full file load into memory) + - Atomic file replacement via temp file + rename for safer writes + - New helper functions: `findAtomInRange()`, `readAtomHeaderAt()`, `copyRange()` + +### Backend + +- **Deezer ISRC Fetching**: Uses ISRCs already present in payloads and caches them, cutting extra API calls +- **SearchAll Allocation**: Preallocated slices to reduce allocations during Deezer search +- **HTTP Client Helper**: Refactored HTTP client creation to use `NewHTTPClientWithTimeout()` helper function across `lyrics.go`, `qobuz.go`, `tidal.go` + +### Technical + +- **Go Backend Changes**: + - `go_backend/extension_providers.go`: Added `Label`, `Copyright`, `Genre` fields to `ExtTrackMetadata`; added mutex locks to all provider methods + - `go_backend/extension_manager.go`: Added `VMMu sync.Mutex` to `LoadedExtension` struct + - `go_backend/extension_runtime.go`: Added `utils.randomUserAgent` function + - `go_backend/extension_runtime_utils.go`: Added `randomUserAgent()` implementation + - `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions + - `go_backend/tidal.go`: Added release date fallback logic + - `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse` + +- **Flutter Changes**: + - `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max) + - `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache + - `lib/main.dart`: Added `CoverCacheManager.initialize()` to app startup + - `lib/screens/*.dart`: All 11 screens updated to use persistent cache manager for CachedNetworkImage + - `lib/providers/download_queue_provider.dart`: Updated `_embedMetadataAndCover()` to accept and embed genre, label, copyright; added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` + - `lib/screens/track_metadata_screen.dart`: Display genre, label, copyright in metadata grid + - `lib/l10n/arb/app_en.arb`: Added `trackGenre`, `trackLabel`, `trackCopyright` localization strings + +### Dependencies + +- Added `flutter_cache_manager: ^3.4.1` (explicit dependency for persistent cache) +- Added `path: ^1.9.0` (for cache directory path handling) + +--- + +## [3.1.2] - 2026-01-19 + +### Added + +- **New Languages**: Added Spanish (es) and Portuguese (pt) translations + - Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125)) + - Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro)) + - Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot)) + +- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching + - Tap the search icon to reveal a dropdown menu with all available search providers + - Shows default provider (Deezer based on metadata source setting) at the top + - Lists all enabled extensions with custom search capability + - Displays extension icons when available + - Checkmark indicates currently selected provider + - Search hint text updates immediately when switching providers + - Re-triggers search automatically if there's existing text in the search bar + - Eliminates need to navigate to Settings > Extensions > Search Provider + +- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions + - Extensions can define `button` type in manifest settings + - Triggers JavaScript function when tapped (e.g., start OAuth flow) + - Useful for authentication, manual sync, or any custom action + +- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information + - Fetches genre and label from Deezer album API for each track + - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files + - Works automatically when Deezer track ID is available (via ISRC matching) + - Supports all download services (Tidal, Qobuz, Amazon) and extension downloads + +- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion + - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality + - When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options + - Available in both the quality picker dialog and default quality settings + - Works with all services (Tidal, Qobuz, Amazon) and extensions + +- **MP3 Metadata Embedding**: Full metadata support for MP3 files + - Cover art embedded using ID3v2 tags + - Synced lyrics embedded (fetched from lrclib.net) + - All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC + - Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3) + +- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds + - Extracts dominant color from cover art using `palette_generator` + - Creates a gradient from dominant color to theme surface color + - Smooth 500ms color transition animation + +- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed) + - More prominent album artwork display + - Larger shadow and rounded corners (20px radius) + - Higher resolution cover caching + +- **Sticky Title**: Title appears in AppBar when scrolling past the info card + - Smooth fade-in animation (200ms) when scrolling down + - Title hidden when header is expanded (shows in info card instead) + - AppBar uses theme color (surface) for clean, native look + - Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens + +- **Artist Name in Album Screen**: Album info card now displays artist name below album title + - Extracted from first track's artist metadata + - Styled with `onSurfaceVariant` color for visual hierarchy + +- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc + - Visual disc separator header showing "Disc 1", "Disc 2", etc. + - Tracks sorted by disc number first, then by track number + - Single-disc albums display normally without separators + - Fixes confusion when albums have duplicate track numbers across discs + +- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section + - Prevents flooding the recents list when downloading full albums + - Groups tracks by album name and artist + - Tapping navigates directly to the downloaded album screen + - Shows the most recent download time for each album + +### Changed + +- **FFmpeg FLAC-to-MP3 Conversion**: Improved conversion process + - MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder) + - Original FLAC file automatically deleted after successful conversion + - New `embedMetadataToMp3()` method for MP3-specific tag embedding + +- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed + - Dark theme: Black background with white text + - Light theme: White background with black text + - Matches modern app behavior for better readability + +### Fixed + +- **MP3 Quality Display in Track Metadata**: Fixed incorrect quality display for MP3 files + - MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate + - History no longer stores FLAC audio specs for converted MP3 files + - Both File Info badges and metadata grid show correct MP3 quality + +- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks + - `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored + - `track_provider.dart`: Added comments explaining why availability check errors are silently ignored + - `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures + +- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization + - Removed redundant `=1` clauses that were overriding `one` plural category + - Affected 10 plural strings including track counts and delete confirmations + - Plurals now correctly handle Russian grammar (1 трек, 2 трека, 5 треков) + +### Dependencies + +- Added `palette_generator: ^0.3.3+4` for cover art color extraction + +--- + ## [3.1.1] - 2026-01-17 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d284332d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f1750732 --- /dev/null +++ b/CONTRIBUTING.md @@ -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/): + +``` +(): + +[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! 💚 diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index fb5ec321..625bfd66 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -284,6 +284,13 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "getDeezerExtendedMetadata" -> { + val trackId = call.argument("track_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getDeezerExtendedMetadata(trackId) + } + result.success(response) + } "convertSpotifyToDeezer" -> { val resourceType = call.argument("resource_type") ?: "" val spotifyId = call.argument("spotify_id") ?: "" @@ -438,6 +445,14 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "invokeExtensionAction" -> { + val extensionId = call.argument("extension_id") ?: "" + val actionName = call.argument("action") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.invokeExtensionActionJSON(extensionId, actionName) + } + result.success(response) + } "searchTracksWithExtensions" -> { val query = call.argument("query") ?: "" val limit = call.argument("limit") ?: 20 diff --git a/build_assets/ffmpeg_service_ios.dart b/build_assets/ffmpeg_service_ios.dart index b386490a..da0543ce 100644 --- a/build_assets/ffmpeg_service_ios.dart +++ b/build_assets/ffmpeg_service_ios.dart @@ -42,17 +42,27 @@ class FFmpegServiceIOS { } /// Convert FLAC to MP3 - static Future 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 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 embedMetadataToMp3({ + required String mp3Path, + String? coverPath, + Map? 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 _convertToId3Tags(Map vorbisMetadata) { + final id3Map = {}; + + 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 isAvailable() async { try { diff --git a/go_backend/amazon.go b/go_backend/amazon.go index b6bb4c40..c16d30eb 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -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") diff --git a/go_backend/cover.go b/go_backend/cover.go index 88d4d29b..fd0ed822 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -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 "" diff --git a/go_backend/deezer.go b/go_backend/deezer.go index a3fcedd5..2fb92c58 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -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,25 @@ 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, 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)) @@ -366,7 +377,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 +462,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 +484,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 +522,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 +560,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 +585,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 +606,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 +634,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 +643,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 +694,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 +836,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:] } diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index 6d31705e..15e80370 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -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 diff --git a/go_backend/exports.go b/go_backend/exports.go index 3206b494..17112d93 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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 @@ -673,9 +629,6 @@ 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 != "" { lyrics, err := ExtractLyrics(filePath) @@ -695,7 +648,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura 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 +663,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 +698,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 +780,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 +923,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 +1106,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 +1258,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 +1354,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 +1413,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 +1516,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) @@ -1802,7 +1777,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 +1877,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 +1934,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 +1989,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 +2002,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { return "", fmt.Errorf("extension store not initialized") } - // Force refresh if requested if forceRefresh { store.FetchRegistry(true) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 857a8dae..baa4ba72 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -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 { @@ -826,7 +809,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 +870,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 +880,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 +931,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.() 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 +} diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 7a7a37f3..7a850a55 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -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 @@ -149,9 +151,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 +172,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 +197,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 +204,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 +281,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)) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 9fe583ea..672a06c9 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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 @@ -144,6 +149,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 +215,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 +265,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 +318,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 +370,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 +440,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 +489,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 +541,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 +795,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 +849,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 +944,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 +1035,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 +1197,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 +1235,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 +1310,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 +1395,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 +1462,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(` diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index a41b4ef6..c01d2665 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -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) } diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index 4e5102ef..ce63b1d8 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -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 } diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go index 889456bb..f5a5b578 100644 --- a/go_backend/extension_runtime_ffmpeg.go +++ b/go_backend/extension_runtime_ffmpeg.go @@ -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{}{ diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 82ccec3b..20b720df 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -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{}{ diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index 61c7b36c..a87365c8 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -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() diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index a44bfd33..73108e20 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -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) diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index cd3819c1..37d86920 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -94,7 +94,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 +112,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 +134,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 +265,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 { diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go index 6f46773c..f76514b3 100644 --- a/go_backend/extension_settings.go +++ b/go_backend/extension_settings.go @@ -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 diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 2a2e1097..370046d7 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -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) diff --git a/go_backend/filename.go b/go_backend/filename.go index 2be92b20..94a17cf8 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -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] diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 0700cfde..fcd0377d 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -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 { diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 5c08b03c..7537a782 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -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() diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 97254ff7..b22b200a 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -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,20 +227,17 @@ 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 @@ -394,7 +375,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 +401,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 +408,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 +461,22 @@ func simplifyTrackName(name string) string { 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 +} diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 25f09dac..f2dac03d 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -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") } diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 88eee90a..2481ecfe 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -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() } diff --git a/go_backend/progress.go b/go_backend/progress.go index cf18b5ee..159f4d6c 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -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) } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 2719b909..40c24d86 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -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() @@ -1120,6 +1082,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { 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 +1097,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") diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go index 1caa54d2..1f2ac1f6 100644 --- a/go_backend/ratelimit.go +++ b/go_backend/ratelimit.go @@ -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 } diff --git a/go_backend/romaji.go b/go_backend/romaji.go index 1e1516e3..d5a73963 100644 --- a/go_backend/romaji.go +++ b/go_backend/romaji.go @@ -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 { diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 63e1bbab..f898af9a 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -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 { diff --git a/go_backend/spotify.go b/go_backend/spotify.go index b88d275f..65b37143 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -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,25 @@ 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"` 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 +192,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 +205,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 +215,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 +237,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 +253,6 @@ type accessTokenResponse struct { TokenType string `json:"token_type"` } -// Internal API response types type image struct { URL string `json:"url"` } @@ -297,7 +278,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 +293,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 +318,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 +364,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 +485,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"` @@ -543,11 +520,9 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s 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 +544,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 +583,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 +593,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 +601,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 +621,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 +646,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 +671,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 +682,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 +728,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 +750,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 +789,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 +874,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 +909,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 +934,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 +944,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 +961,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 +986,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 +993,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 +1000,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 } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index d537a954..f489741a 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -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,7 @@ 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 +433,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 +558,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 +567,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 +578,6 @@ type TidalDownloadInfo struct { SampleRate int } -// tidalAPIResult holds the result from a parallel API request type tidalAPIResult struct { apiURL string info TidalDownloadInfo @@ -622,9 +585,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 +600,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,8 +630,7 @@ 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" { + if v2Response.Data.AssetPresentation == "PREVIEW" { resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} return } @@ -715,7 +673,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 +693,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 +707,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 +813,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,15 +899,11 @@ 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) { + if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1135,7 +1084,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 +1097,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 +1110,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 +1128,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 +1138,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 +1160,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 +1169,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 +1193,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 +1202,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 +1214,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 +1228,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 +1239,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 +1258,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 +1287,6 @@ func cleanTitle(title string) string { break } - // Same for brackets for { startBracket := strings.LastIndex(cleaned, "[") endBracket := strings.LastIndex(cleaned, "]") @@ -1385,7 +1307,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 +1317,6 @@ func cleanTitle(title string) string { } } - // Remove multiple spaces for strings.Contains(cleaned, " ") { cleaned = strings.ReplaceAll(cleaned, " ", " ") } @@ -1404,48 +1324,29 @@ 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 +1354,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 +1373,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 +1384,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 +1405,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 +1439,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 +1455,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 +1468,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 +1488,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 +1521,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 +1538,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 +1555,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 +1572,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 +1584,28 @@ 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) + } + 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: track.TrackNumber, TotalTracks: req.TotalTracks, - DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal - ISRC: track.ISRC, // Use actual ISRC from Tidal + DiscNumber: track.VolumeNumber, + ISRC: track.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } var coverData []byte @@ -1724,46 +1614,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{ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 789a950d..c6a373d7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -227,6 +227,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 @@ -375,6 +382,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 diff --git a/lib/app.dart b/lib/app.dart index df0f2158..9d6c7b8b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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( diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 187318d6..1dc767de 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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.1.2'; + static const String buildNumber = '61'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c3781e32..4cdaa83d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -107,6 +107,7 @@ abstract class AppLocalizations { Locale('de'), Locale('en'), Locale('es'), + Locale('es', 'ES'), Locale('fr'), Locale('hi'), Locale('id'), @@ -114,6 +115,7 @@ abstract class AppLocalizations { Locale('ko'), Locale('nl'), Locale('pt'), + Locale('pt', 'PT'), Locale('ru'), Locale('zh'), Locale('zh', 'CN'), @@ -816,6 +818,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: @@ -1680,6 +1688,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 +2618,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 +2876,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: @@ -3252,6 +3338,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: @@ -3588,6 +3704,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: @@ -3663,6 +3785,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) { diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index cb3b2481..2dd62b0d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -13,56 +13,57 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; @override - String get navHome => 'Home'; + String get navHome => 'Startseite'; @override - String get navHistory => 'History'; + String get navHistory => 'Verlauf'; @override - String get navSettings => 'Settings'; + String get navSettings => 'Einstellungen'; @override String get navStore => 'Store'; @override - String get homeTitle => 'Home'; + String get homeTitle => 'Startseite'; @override - String get homeSearchHint => 'Paste Spotify URL or search...'; + String get homeSearchHint => 'Spotify-URL einfügen oder suchen...'; @override String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; + return 'Mit $extensionName suchen...'; } @override - String get homeSubtitle => 'Paste a Spotify link or search by name'; + String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen'; @override - String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + String get homeSupports => + 'Unterstützt: Titel, Album, Playlist, Künstler-URLs'; @override - String get homeRecent => 'Recent'; + String get homeRecent => 'Zuletzt'; @override - String get historyTitle => 'History'; + String get historyTitle => 'Verlauf'; @override String historyDownloading(int count) { - return 'Downloading ($count)'; + return 'Wird heruntergeladen ($count)'; } @override - String get historyDownloaded => 'Downloaded'; + String get historyDownloaded => 'Heruntergeladen'; @override - String get historyFilterAll => 'All'; + String get historyFilterAll => 'Alle'; @override - String get historyFilterAlbums => 'Albums'; + String get historyFilterAlbums => 'Alben'; @override String get historyFilterSingles => 'Singles'; @@ -72,8 +73,8 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @@ -83,93 +84,95 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count albums', - one: '1 album', + other: '$count Alben', + one: '1 Album', ); return '$_temp0'; } @override - String get historyNoDownloads => 'No download history'; + String get historyNoDownloads => 'Kein Download-Verlauf'; @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; + String get historyNoDownloadsSubtitle => + 'Heruntergeladene Titel werden hier angezeigt'; @override - String get historyNoAlbums => 'No album downloads'; + String get historyNoAlbums => 'Keine Album-Downloads'; @override String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; + 'Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen'; @override - String get historyNoSingles => 'No single downloads'; + String get historyNoSingles => 'Keine Einzel-Downloads'; @override String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; + 'Einzelne Titel-Downloads werden hier angezeigt'; @override - String get settingsTitle => 'Settings'; + String get settingsTitle => 'Einstellungen'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => 'Herunterladen'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => 'Erscheinungsbild'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => 'Optionen'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => 'Erweiterungen'; @override - String get settingsAbout => 'About'; + String get settingsAbout => 'Über'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => 'Herunterladen'; @override - String get downloadLocation => 'Download Location'; + String get downloadLocation => 'Download-Speicherort'; @override - String get downloadLocationSubtitle => 'Choose where to save files'; + String get downloadLocationSubtitle => + 'Wählen Sie den Speicherort für Dateien'; @override - String get downloadLocationDefault => 'Default location'; + String get downloadLocationDefault => 'Standard-Speicherort'; @override - String get downloadDefaultService => 'Default Service'; + String get downloadDefaultService => 'Standard-Dienst'; @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + String get downloadDefaultServiceSubtitle => 'Dienst für Downloads'; @override - String get downloadDefaultQuality => 'Default Quality'; + String get downloadDefaultQuality => 'Standard-Qualität'; @override - String get downloadAskQuality => 'Ask Quality Before Download'; + String get downloadAskQuality => 'Qualität vor Download abfragen'; @override String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; + 'Qualitätsauswahl für jeden Download anzeigen'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => 'Dateinamenformat'; @override - String get downloadFolderOrganization => 'Folder Organization'; + String get downloadFolderOrganization => 'Ordnerstruktur'; @override - String get downloadSeparateSingles => 'Separate Singles'; + String get downloadSeparateSingles => 'Singles trennen'; @override String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; + 'Einzelne Titel in separatem Ordner speichern'; @override - String get qualityBest => 'Best Available'; + String get qualityBest => 'Beste Qualität'; @override String get qualityFlac => 'FLAC'; @@ -181,179 +184,186 @@ class AppLocalizationsDe extends AppLocalizations { String get quality128 => '128 kbps'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => 'Erscheinungsbild'; @override - String get appearanceTheme => 'Theme'; + String get appearanceTheme => 'Design'; @override String get appearanceThemeSystem => 'System'; @override - String get appearanceThemeLight => 'Light'; + String get appearanceThemeLight => 'Hell'; @override - String get appearanceThemeDark => 'Dark'; + String get appearanceThemeDark => 'Dunkel'; @override - String get appearanceDynamicColor => 'Dynamic Color'; + String get appearanceDynamicColor => 'Dynamische Farben'; @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + String get appearanceDynamicColorSubtitle => + 'Farben von Ihrem Hintergrundbild verwenden'; @override - String get appearanceAccentColor => 'Accent Color'; + String get appearanceAccentColor => 'Akzentfarbe'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => 'Verlaufsansicht'; @override - String get appearanceHistoryViewList => 'List'; + String get appearanceHistoryViewList => 'Liste'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'Raster'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => 'Optionen'; @override - String get optionsSearchSource => 'Search Source'; + String get optionsSearchSource => 'Suchquelle'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => 'Primärer Anbieter'; @override String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; + 'Dienst für die Suche nach Titelnamen.'; @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return 'Erweiterung verwenden: $extensionName'; } @override String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + 'Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => 'Automatischer Fallback'; @override String get optionsAutoFallbackSubtitle => - 'Try other services if download fails'; + 'Andere Dienste versuchen, wenn Download fehlschlägt'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden'; @override - String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + String get optionsUseExtensionProvidersOn => + 'Erweiterungen werden zuerst versucht'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => + 'Nur integrierte Anbieter verwenden'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => 'Liedtexte einbetten'; @override String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; + 'Synchronisierte Liedtexte in FLAC-Dateien einbetten'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => 'Maximale Cover-Qualität'; @override String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; + 'Cover in höchster Auflösung herunterladen'; @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; + String get optionsConcurrentDownloads => 'Parallele Downloads'; @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + String get optionsConcurrentSequential => 'Sequentiell (1 gleichzeitig)'; @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count parallele Downloads'; } @override String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; + 'Parallele Downloads können Ratenlimitierung auslösen'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => 'Erweiterungs-Store'; @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + String get optionsExtensionStoreSubtitle => + 'Store-Tab in Navigation anzeigen'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => 'Nach Updates suchen'; @override String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; + 'Benachrichtigen, wenn neue Version verfügbar'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => 'Update-Kanal'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => 'Nur stabile Versionen'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => 'Vorschau-Versionen erhalten'; @override String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; + 'Vorschau kann Fehler oder unvollständige Funktionen enthalten'; @override - String get optionsClearHistory => 'Clear Download History'; + String get optionsClearHistory => 'Download-Verlauf löschen'; @override String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; + 'Alle heruntergeladenen Titel aus dem Verlauf entfernen'; @override - String get optionsDetailedLogging => 'Detailed Logging'; + String get optionsDetailedLogging => 'Detaillierte Protokollierung'; @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + String get optionsDetailedLoggingOn => + 'Detaillierte Protokolle werden aufgezeichnet'; @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; + String get optionsDetailedLoggingOff => 'Für Fehlerberichte aktivieren'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Spotify-Anmeldedaten'; @override String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; + return 'Client-ID: $clientId...'; } @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + String get optionsSpotifyCredentialsRequired => + 'Erforderlich - zum Konfigurieren tippen'; @override String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + 'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => 'Erweiterungen'; @override - String get extensionsInstalled => 'Installed Extensions'; + String get extensionsInstalled => 'Installierte Erweiterungen'; @override - String get extensionsNone => 'No extensions installed'; + String get extensionsNone => 'Keine Erweiterungen installiert'; @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + String get extensionsNoneSubtitle => + 'Erweiterungen aus dem Store-Tab installieren'; @override - String get extensionsEnabled => 'Enabled'; + String get extensionsEnabled => 'Aktiviert'; @override - String get extensionsDisabled => 'Disabled'; + String get extensionsDisabled => 'Deaktiviert'; @override String extensionsVersion(String version) { @@ -362,78 +372,84 @@ class AppLocalizationsDe extends AppLocalizations { @override String extensionsAuthor(String author) { - return 'by $author'; + return 'von $author'; } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => 'Deinstallieren'; @override - String get extensionsSetAsSearch => 'Set as Search Provider'; + String get extensionsSetAsSearch => 'Als Suchanbieter festlegen'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => 'Erweiterungs-Store'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => 'Erweiterungen suchen...'; @override - String get storeInstall => 'Install'; + String get storeInstall => 'Installieren'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => 'Installiert'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => 'Aktualisieren'; @override - String get aboutTitle => 'About'; + String get aboutTitle => 'Über'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => 'Mitwirkende'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => 'Mobile-Version Entwickler'; @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + String get aboutOriginalCreator => 'Schöpfer des ursprünglichen SpotiFLAC'; @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 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'; @@ -443,25 +459,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 => @@ -894,6 +910,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'; @@ -1427,6 +1448,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'; @@ -1539,6 +1589,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'; @@ -1782,6 +1841,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'; @@ -1973,6 +2048,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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3076e225..de382bda 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -402,6 +402,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'; @@ -894,6 +897,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 +1435,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 +1576,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'; @@ -1782,6 +1828,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'; @@ -1973,6 +2035,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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a5adb890..0ff7baad 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -402,6 +402,9 @@ class AppLocalizationsEs 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'; @@ -894,6 +897,11 @@ class AppLocalizationsEs 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 +1435,35 @@ class AppLocalizationsEs 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 +1576,15 @@ class AppLocalizationsEs 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'; @@ -1782,6 +1828,22 @@ class AppLocalizationsEs 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'; @@ -1973,6 +2035,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; @@ -1998,3 +2065,2029 @@ class AppLocalizationsEs extends AppLocalizations { return 'Error: $message'; } } + +/// The translations for Spanish Castilian, as used in Spain (`es_ES`). +class AppLocalizationsEsEs extends AppLocalizationsEs { + AppLocalizationsEsEs() : super('es_ES'); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get appDescription => + 'Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; + + @override + String get navHome => 'Inicio'; + + @override + String get navHistory => 'Historial'; + + @override + String get navSettings => 'Ajustes'; + + @override + String get navStore => 'Tienda'; + + @override + String get homeTitle => 'Inicio'; + + @override + String get homeSearchHint => 'Pegar URL Spotify o buscar...'; + + @override + String homeSearchHintExtension(String extensionName) { + return 'Buscar con $extensionName...'; + } + + @override + String get homeSubtitle => 'Pegar enlace de Spotify o buscar por nombre'; + + @override + String get homeSupports => + 'Soportes: Pista, Álbum, Lista de reproducción, URLs de Artistas'; + + @override + String get homeRecent => 'Recientes'; + + @override + String get historyTitle => 'Historial'; + + @override + String historyDownloading(int count) { + return 'Descargando ($count)'; + } + + @override + String get historyDownloaded => 'Descargado'; + + @override + String get historyFilterAll => 'Todo'; + + @override + String get historyFilterAlbums => 'Álbumes'; + + @override + String get historyFilterSingles => 'Pistas'; + + @override + String historyTracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count pistas', + one: '1 pista', + ); + return '$_temp0'; + } + + @override + String historyAlbumsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count álbumes', + one: '1 álbum', + ); + return '$_temp0'; + } + + @override + String get historyNoDownloads => 'No hay historial de descargas'; + + @override + String get historyNoDownloadsSubtitle => + 'Las pistas descargadas aparecerán aquí'; + + @override + String get historyNoAlbums => 'No hay descargas de álbum'; + + @override + String get historyNoAlbumsSubtitle => + 'Descargar múltiples pistas de un álbum para verlas aquí'; + + @override + String get historyNoSingles => 'No hay descargas'; + + @override + String get historyNoSinglesSubtitle => + 'Las descargas de una sola pista aparecerán aquí'; + + @override + String get settingsTitle => 'Ajustes'; + + @override + String get settingsDownload => 'Descargar'; + + @override + String get settingsAppearance => 'Apariencia'; + + @override + String get settingsOptions => 'Opciones'; + + @override + String get settingsExtensions => 'Extensiones'; + + @override + String get settingsAbout => 'Acerca de'; + + @override + String get downloadTitle => 'Descargar'; + + @override + String get downloadLocation => 'Ubicación de descarga'; + + @override + String get downloadLocationSubtitle => 'Elija dónde guardar los archivos'; + + @override + String get downloadLocationDefault => 'Ubicación predeterminada'; + + @override + String get downloadDefaultService => 'Servicio por defecto'; + + @override + String get downloadDefaultServiceSubtitle => 'Servicio usado para descargas'; + + @override + String get downloadDefaultQuality => 'Calidad por defecto'; + + @override + String get downloadAskQuality => 'Preguntar calidad antes de descargar'; + + @override + String get downloadAskQualitySubtitle => + 'Mostrar selector de calidad para cada descarga'; + + @override + String get downloadFilenameFormat => 'Formato del nombre del archivo'; + + @override + String get downloadFolderOrganization => 'Organización de carpetas'; + + @override + String get downloadSeparateSingles => 'Separar Pistas'; + + @override + String get downloadSeparateSinglesSubtitle => + 'Colocar pistas individuales en una carpeta separada'; + + @override + String get qualityBest => 'Mejor disponible'; + + @override + String get qualityFlac => 'FLAC'; + + @override + String get quality320 => '320 kbps'; + + @override + String get quality128 => '128 kbps'; + + @override + String get appearanceTitle => 'Apariencia'; + + @override + String get appearanceTheme => 'Tema'; + + @override + String get appearanceThemeSystem => 'Sistema'; + + @override + String get appearanceThemeLight => 'Claro'; + + @override + String get appearanceThemeDark => 'Oscuro'; + + @override + String get appearanceDynamicColor => 'Color dinámico'; + + @override + String get appearanceDynamicColorSubtitle => + 'Usar colores de tu fondo de pantalla'; + + @override + String get appearanceAccentColor => 'Color Secundario'; + + @override + String get appearanceHistoryView => 'Vista de Historial'; + + @override + String get appearanceHistoryViewList => 'Lista'; + + @override + String get appearanceHistoryViewGrid => 'Cuadrícula'; + + @override + String get optionsTitle => 'Opciones'; + + @override + String get optionsSearchSource => 'Buscar Fuente'; + + @override + String get optionsPrimaryProvider => 'Proveedor Principal'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Servicio usado al buscar por nombre de la pista.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Usando la extensión: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Toque Deezer o Spotify para volver desde la extensión'; + + @override + String get optionsAutoFallback => 'Alternativa automática'; + + @override + String get optionsAutoFallbackSubtitle => + 'Pruebe otros servicios si falla la descarga'; + + @override + String get optionsUseExtensionProviders => 'Usar proveedores de extensiones'; + + @override + String get optionsUseExtensionProvidersOn => + 'Las extensiones serán probadas primero'; + + @override + String get optionsUseExtensionProvidersOff => + 'Utilizando sólo proveedores integrados'; + + @override + String get optionsEmbedLyrics => 'Incrustar Letras'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Insertar letras sincronizadas en archivos FLAC'; + + @override + String get optionsMaxQualityCover => 'Carátula de calidad máxima'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Descargar carátula de resolución máxima'; + + @override + String get optionsConcurrentDownloads => 'Descargas Simultáneas'; + + @override + String get optionsConcurrentSequential => 'Secuencial (1 a la vez)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count descargas paralelas'; + } + + @override + String get optionsConcurrentWarning => + 'Las descargas paralelas pueden activar la limitación de velocidad'; + + @override + String get optionsExtensionStore => 'Tienda de extensiones'; + + @override + String get optionsExtensionStoreSubtitle => + 'Mostrar pestaña de tienda en la navegación'; + + @override + String get optionsCheckUpdates => 'Comprobar actualizaciones'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notificar cuando una nueva versión esté disponible'; + + @override + String get optionsUpdateChannel => 'Tipo de actualizaciones'; + + @override + String get optionsUpdateChannelStable => 'Sólo versiones estables'; + + @override + String get optionsUpdateChannelPreview => 'Versión preliminar'; + + @override + String get optionsUpdateChannelWarning => + 'La Versión preliminar puede contener errores o características incompletas'; + + @override + String get optionsClearHistory => 'Borrar el historial de descargas'; + + @override + String get optionsClearHistorySubtitle => + 'Eliminar todas las pistas descargadas del historial'; + + @override + String get optionsDetailedLogging => 'Registro detallado'; + + @override + String get optionsDetailedLoggingOn => + 'Registros detallados están siendo registrados'; + + @override + String get optionsDetailedLoggingOff => 'Habilitar para informes de errores'; + + @override + String get optionsSpotifyCredentials => 'Credenciales de Spotify'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'ID de cliente: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => + 'Requerido - toque para configurar'; + + @override + String get optionsSpotifyWarning => + 'Spotify requiere tus propias credenciales API. Obténgalas gratis de developer.spotify.com'; + + @override + String get extensionsTitle => 'Extensiones'; + + @override + String get extensionsInstalled => 'Extensiones instaladas'; + + @override + String get extensionsNone => 'No hay extensiones instaladas'; + + @override + String get extensionsNoneSubtitle => + 'Instalar extensiones desde la pestaña Tienda'; + + @override + String get extensionsEnabled => 'Habilitado'; + + @override + String get extensionsDisabled => 'Deshabilitado'; + + @override + String extensionsVersion(String version) { + return 'Versión $version'; + } + + @override + String extensionsAuthor(String author) { + return 'por $author'; + } + + @override + String get extensionsUninstall => 'Desinstalar'; + + @override + String get extensionsSetAsSearch => 'Establecer como proveedor de búsqueda'; + + @override + String get storeTitle => 'Tienda de extensiones'; + + @override + String get storeSearch => 'Buscar extensiones...'; + + @override + String get storeInstall => 'Instalar'; + + @override + String get storeInstalled => 'Instalada'; + + @override + String get storeUpdate => 'Actualizar'; + + @override + String get aboutTitle => 'Acerca de'; + + @override + String get aboutContributors => 'Colaboradores'; + + @override + String get aboutMobileDeveloper => 'Desarrollador de versiones móviles'; + + @override + String get aboutOriginalCreator => 'Creador original de SpotiFLAC'; + + @override + String get aboutLogoArtist => + '¡El talentoso artista que creó nuestro hermoso logo!'; + + @override + String get aboutSpecialThanks => 'Agradecimientos especiales'; + + @override + String get aboutLinks => 'Enlaces'; + + @override + String get aboutMobileSource => 'Código fuente móvil'; + + @override + String get aboutPCSource => 'Código fuente de PC'; + + @override + String get aboutReportIssue => 'Reportar un problema'; + + @override + String get aboutReportIssueSubtitle => + 'Reporta cualquier problema que encuentres'; + + @override + String get aboutFeatureRequest => 'Sugerir una función'; + + @override + String get aboutFeatureRequestSubtitle => + 'Sugerir nuevas funciones para la aplicación'; + + @override + String get aboutSupport => 'Soporte'; + + @override + String get aboutBuyMeCoffee => 'Invítame a un café'; + + @override + String get aboutBuyMeCoffeeSubtitle => 'Apoyar el desarrollo en Ko-fi'; + + @override + String get aboutApp => 'Aplicación'; + + @override + String get aboutVersion => 'Versión'; + + @override + String get aboutBinimumDesc => + 'El creador de la API QQDL & Hi-Fi. ¡Sin esta API, las descargas de Tidal no existiría!'; + + @override + String get aboutSachinsenalDesc => + 'El creador original del proyecto Hi-Fi. ¡La base de la integración de Tidal!'; + + @override + String get aboutDoubleDouble => 'DoubleDouble'; + + @override + String get aboutDoubleDoubleDesc => + 'API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!'; + + @override + String get aboutDabMusic => 'Música DAB'; + + @override + String get aboutDabMusicDesc => + 'La mejor API de streaming de Qobuz. ¡Las descargas de Hi-Res no serían posibles sin esto!'; + + @override + String get aboutAppDescription => + 'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; + + @override + String get albumTitle => 'Álbum'; + + @override + String albumTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count pistas', + one: '1 pista', + ); + return '$_temp0'; + } + + @override + String get albumDownloadAll => 'Descargar Todo'; + + @override + String get albumDownloadRemaining => 'Descargas Restantes'; + + @override + String get playlistTitle => 'Lista de reproducción'; + + @override + String get artistTitle => 'Artista'; + + @override + String get artistAlbums => 'Álbumes'; + + @override + String get artistSingles => 'Pistas y EPs'; + + @override + String get artistCompilations => 'Compilaciones'; + + @override + String artistReleases(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lanzamientos', + one: '1 lanzamiento', + ); + return '$_temp0'; + } + + @override + String get artistPopular => 'Populares'; + + @override + String artistMonthlyListeners(String count) { + return '$count oyentes mensuales'; + } + + @override + String get trackMetadataTitle => 'Información de pista'; + + @override + String get trackMetadataArtist => 'Artista'; + + @override + String get trackMetadataAlbum => 'Álbum'; + + @override + String get trackMetadataDuration => 'Duración'; + + @override + String get trackMetadataQuality => 'Calidad'; + + @override + String get trackMetadataPath => 'Ruta del archivo'; + + @override + String get trackMetadataDownloadedAt => 'Descargado'; + + @override + String get trackMetadataService => 'Servicio'; + + @override + String get trackMetadataPlay => 'Reproducir'; + + @override + String get trackMetadataShare => 'Compartir'; + + @override + String get trackMetadataDelete => 'Eliminar'; + + @override + String get trackMetadataRedownload => 'Volver a descargar'; + + @override + String get trackMetadataOpenFolder => 'Abrir carpeta'; + + @override + String get setupTitle => 'Bienvenido a SpotiFLAC'; + + @override + String get setupSubtitle => 'Comencemos'; + + @override + String get setupStoragePermission => 'Permiso de almacenamiento'; + + @override + String get setupStoragePermissionSubtitle => + 'Necesario para guardar los archivos descargados'; + + @override + String get setupStoragePermissionGranted => 'Permiso aprobado'; + + @override + String get setupStoragePermissionDenied => 'Permiso denegado'; + + @override + String get setupGrantPermission => 'Conceder permiso'; + + @override + String get setupDownloadLocation => 'Ubicación de descarga'; + + @override + String get setupChooseFolder => 'Seleccionar Carpeta'; + + @override + String get setupContinue => 'Continuar'; + + @override + String get setupSkip => 'Omitir por ahora'; + + @override + String get setupStorageAccessRequired => 'Acceso al almacenamiento requerido'; + + @override + String get setupStorageAccessMessage => + 'SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.'; + + @override + String get setupOpenSettings => 'Abrir ajustes'; + + @override + String get setupPermissionDeniedMessage => + 'Permiso denegado. Por favor, conceda todos los permisos para continuar.'; + + @override + String setupPermissionRequired(String permissionType) { + return 'Permiso de $permissionType requerido'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return 'Se requiere un permiso $permissionType para la mejor experiencia. Puedes cambiar esto más tarde en ajustes.'; + } + + @override + String get setupSelectDownloadFolder => 'Seleccionar carpeta de descarga'; + + @override + String get setupUseDefaultFolder => '¿Usar carpeta por defecto?'; + + @override + String get setupNoFolderSelected => + 'No se ha seleccionado ninguna carpeta. ¿Desea utilizar la carpeta por defecto?'; + + @override + String get setupUseDefault => 'Usar por defecto'; + + @override + String get setupDownloadLocationTitle => 'Ubicación de descarga'; + + @override + String get setupDownloadLocationIosMessage => + 'En iOS, las descargas se guardan en la carpeta de documentos de la aplicación. Puede acceder a ellas desde la aplicación Archivos.'; + + @override + String get setupAppDocumentsFolder => 'Carpeta de documentos de App'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recomendado - accesible desde la aplicación Archivos'; + + @override + String get setupChooseFromFiles => 'Elegir de archivos'; + + @override + String get setupChooseFromFilesSubtitle => + 'Seleccione iCloud u otra ubicación'; + + @override + String get setupIosEmptyFolderWarning => + 'Limitación de iOS: No se pueden seleccionar carpetas vacías. Elige una carpeta con al menos un archivo.'; + + @override + String get setupDownloadInFlac => 'Descargar pistas de Spotify en FLAC'; + + @override + String get setupStepStorage => 'Almacenamiento'; + + @override + String get setupStepNotification => 'Notificación'; + + @override + String get setupStepFolder => 'Carpeta'; + + @override + String get setupStepSpotify => 'Spotify'; + + @override + String get setupStepPermission => 'Permiso'; + + @override + String get setupStorageGranted => '¡Permiso de almacenamiento concedido!'; + + @override + String get setupStorageRequired => 'Permiso de almacenamiento requerido'; + + @override + String get setupStorageDescription => + 'SpotiFLAC necesita permiso de almacenamiento para guardar sus archivos de música descargados.'; + + @override + String get setupNotificationGranted => + '¡Acceso a las notificaciones permitido!'; + + @override + String get setupNotificationEnable => 'Activar notificaciones'; + + @override + String get setupNotificationDescription => + 'Recibe notificaciones cuando las descargas completen o requieran atención.'; + + @override + String get setupFolderSelected => '¡Carpeta de descarga seleccionada!'; + + @override + String get setupFolderChoose => 'Cambiar carpeta de descargas'; + + @override + String get setupFolderDescription => + 'Seleccione una carpeta donde se guardará la música descargada.'; + + @override + String get setupChangeFolder => 'Cambiar carpeta'; + + @override + String get setupSelectFolder => 'Seleccionar Carpeta'; + + @override + String get setupSpotifyApiOptional => 'API de Spotify (opcional)'; + + @override + String get setupSpotifyApiDescription => + 'Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.'; + + @override + String get setupUseSpotifyApi => 'Usar API de Spotify'; + + @override + String get setupEnterCredentialsBelow => + 'Ingresa tus credenciales a continuación'; + + @override + String get setupUsingDeezer => 'Usando Deezer (no se necesita cuenta)'; + + @override + String get setupEnterClientId => 'Introduzca el ID de cliente de Spotify'; + + @override + String get setupEnterClientSecret => 'Ingresa el Client Secret de Spotify'; + + @override + String get setupGetFreeCredentials => + 'Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.'; + + @override + String get setupEnableNotifications => 'Activar notificaciones'; + + @override + String get setupProceedToNextStep => + 'Ahora puedes continuar con el siguiente paso.'; + + @override + String get setupNotificationProgressDescription => + 'Recibirás notificaciones de progreso de descargas.'; + + @override + String get setupNotificationBackgroundDescription => + 'Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.'; + + @override + String get setupSkipForNow => 'Omitir por ahora'; + + @override + String get setupBack => 'Atrás'; + + @override + String get setupNext => 'Siguiente'; + + @override + String get setupGetStarted => 'Empezar'; + + @override + String get setupSkipAndStart => 'Saltar y empezar'; + + @override + String get setupAllowAccessToManageFiles => + 'Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.'; + + @override + String get setupGetCredentialsFromSpotify => + 'Obtener credenciales de developer.spotify.com'; + + @override + String get dialogCancel => 'Cancelar'; + + @override + String get dialogOk => 'Aceptar'; + + @override + String get dialogSave => 'Guardar'; + + @override + String get dialogDelete => 'Eliminar'; + + @override + String get dialogRetry => 'Volver a intentar'; + + @override + String get dialogClose => 'Cerrar'; + + @override + String get dialogYes => 'Sí'; + + @override + String get dialogNo => 'No'; + + @override + String get dialogClear => 'Borrar'; + + @override + String get dialogConfirm => 'Confirmar'; + + @override + String get dialogDone => 'Hecho'; + + @override + String get dialogImport => 'Importar'; + + @override + String get dialogDiscard => 'Descartar'; + + @override + String get dialogRemove => 'Eliminar'; + + @override + String get dialogUninstall => 'Desinstalar'; + + @override + String get dialogDiscardChanges => '¿Descartar cambios?'; + + @override + String get dialogUnsavedChanges => + 'Tienes cambios sin guardar. ¿Quieres descartarlos?'; + + @override + String get dialogDownloadFailed => 'Descarga fallida'; + + @override + String get dialogTrackLabel => 'Pista:'; + + @override + String get dialogArtistLabel => 'Artista:'; + + @override + String get dialogErrorLabel => 'Error:'; + + @override + String get dialogClearAll => 'Eliminar todo'; + + @override + String get dialogClearAllDownloads => + '¿Estás seguro de que quieres borrar todas las descargas?'; + + @override + String get dialogRemoveFromDevice => '¿Eliminar del dispositivo?'; + + @override + String get dialogRemoveExtension => 'Eliminar extensión'; + + @override + String get dialogRemoveExtensionMessage => + '¿Estás seguro de que quieres eliminar esta extensión? Esto no se puede deshacer.'; + + @override + String get dialogUninstallExtension => '¿Desinstalar extensión?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return '¿Estás seguro de que quieres eliminar $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Borrar historial'; + + @override + String get dialogClearHistoryMessage => + '¿Estás seguro de que quieres borrar todo el historial de descargas? Esta acción no se puede deshacer.'; + + @override + String get dialogDeleteSelectedTitle => 'Borrar Seleccionados'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¿Eliminar $count $_temp0 del historial?\n\nEsto también eliminará los archivos del almacenamiento.'; + } + + @override + String get dialogImportPlaylistTitle => 'Importar lista de reproducción'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Se han encontrado pistas $count en CSV. ¿Añadirlas para descargar la cola?'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Añadido \"$trackName\" a la cola'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Añadidas pistas $count a la cola'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" ya descargado'; + } + + @override + String get snackbarHistoryCleared => 'Historial borrado'; + + @override + String get snackbarCredentialsSaved => 'Credenciales guardadas'; + + @override + String get snackbarCredentialsCleared => 'Credenciales borradas'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return 'Eliminado $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'No se puede abrir el archivo: $error'; + } + + @override + String get snackbarFillAllFields => 'Por favor, completa todos los campos'; + + @override + String get snackbarViewQueue => 'Ver cola'; + + @override + String snackbarFailedToLoad(String error) { + return 'Error al cargar: $error'; + } + + @override + String snackbarUrlCopied(String platform) { + return 'URL $platform copiada al portapapeles'; + } + + @override + String get snackbarFileNotFound => 'Archivo no encontrado'; + + @override + String get snackbarSelectExtFile => + 'Por favor, seleccione un archivo .spotiflac-ext'; + + @override + String get snackbarProviderPrioritySaved => 'Prioridad de proveedor guardada'; + + @override + String get snackbarMetadataProviderSaved => + 'Prioridad de proveedor de metadatos guardada'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName instalado.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName actualizada.'; + } + + @override + String get snackbarFailedToInstall => 'Fallo al instalar la extensión'; + + @override + String get snackbarFailedToUpdate => 'Error al actualizar la extensión'; + + @override + String get errorRateLimited => 'Límite Excedido'; + + @override + String get errorRateLimitedMessage => + 'Demasiadas solicitudes. Por favor, espere un momento antes de buscar de nuevo.'; + + @override + String errorFailedToLoad(String item) { + return 'Error al cargar $item'; + } + + @override + String get errorNoTracksFound => 'No se encontraron pistas'; + + @override + String errorMissingExtensionSource(String item) { + return 'No se puede cargar $item: falta una fuente de extensión'; + } + + @override + String get statusQueued => 'En cola'; + + @override + String get statusDownloading => 'Descargando'; + + @override + String get statusFinalizing => 'Finalizando'; + + @override + String get statusCompleted => 'Completado'; + + @override + String get statusFailed => 'Error'; + + @override + String get statusSkipped => 'Omitido'; + + @override + String get statusPaused => 'Pausado'; + + @override + String get actionPause => 'Pausar'; + + @override + String get actionResume => 'Reanudar'; + + @override + String get actionCancel => 'Cancelar'; + + @override + String get actionStop => 'Detener'; + + @override + String get actionSelect => 'Seleccionar'; + + @override + String get actionSelectAll => 'Seleccionar Todo'; + + @override + String get actionDeselect => 'Deseleccionar'; + + @override + String get actionPaste => 'Pegar'; + + @override + String get actionImportCsv => 'Importar CSV'; + + @override + String get actionRemoveCredentials => 'Eliminar credenciales'; + + @override + String get actionSaveCredentials => 'Guardar credenciales'; + + @override + String selectionSelected(int count) { + return '$count seleccionado'; + } + + @override + String get selectionAllSelected => 'Todas las pistas seleccionadas'; + + @override + String get selectionTapToSelect => 'Toca las pistas para seleccionar'; + + @override + String selectionDeleteTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¡Eliminar $count $_temp0'; + } + + @override + String get selectionSelectToDelete => 'Seleccionar pistas a eliminar'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Obteniendo metadatos... $current/$total'; + } + + @override + String get progressReadingCsv => 'Leyendo CSV...'; + + @override + String get searchSongs => 'Canciones'; + + @override + String get searchArtists => 'Artistas'; + + @override + String get searchAlbums => 'Álbumes'; + + @override + String get searchPlaylists => 'Listas de reproducción'; + + @override + String get tooltipPlay => 'Reproducir'; + + @override + String get tooltipCancel => 'Cancelar'; + + @override + String get tooltipStop => 'Detener'; + + @override + String get tooltipRetry => 'Volver a intentar'; + + @override + String get tooltipRemove => 'Eliminar'; + + @override + String get tooltipClear => 'Borrar'; + + @override + String get tooltipPaste => 'Pegar'; + + @override + String get filenameFormat => 'Formato del nombre del archivo'; + + @override + String filenameFormatPreview(String preview) { + return 'Vista previa: $preview'; + } + + @override + String get filenameAvailablePlaceholders => 'Marcadores disponibles:'; + + @override + String filenameHint(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get folderOrganization => 'Organización de carpetas'; + + @override + String get folderOrganizationNone => 'Ninguna organización'; + + @override + String get folderOrganizationByArtist => 'Por Artista'; + + @override + String get folderOrganizationByAlbum => 'Por Álbum'; + + @override + String get folderOrganizationByArtistAlbum => 'Artista/Álbum'; + + @override + String get folderOrganizationDescription => + 'Organizar los archivos descargados en carpetas'; + + @override + String get folderOrganizationNoneSubtitle => + 'Todos los archivos de la carpeta de descargas'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Carpeta separada para cada artista'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Carpeta separada para cada artista'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Carpetas organizadas por artista y álbum'; + + @override + String get updateAvailable => 'Actualización Disponible'; + + @override + String updateNewVersion(String version) { + return 'Versión $version está disponible'; + } + + @override + String get updateDownload => 'Descargar'; + + @override + String get updateLater => 'Más tarde'; + + @override + String get updateChangelog => 'Historial de cambios'; + + @override + String get updateStartingDownload => 'Iniciando descarga...'; + + @override + String get updateDownloadFailed => 'Descarga fallida'; + + @override + String get updateFailedMessage => 'Error al descargar la actualización'; + + @override + String get updateNewVersionReady => 'Una nueva versión está lista'; + + @override + String get updateCurrent => 'Actual'; + + @override + String get updateNew => 'Nuevo'; + + @override + String get updateDownloading => 'Descargando...'; + + @override + String get updateWhatsNew => 'Novedades'; + + @override + String get updateDownloadInstall => 'Descargar & Instalar'; + + @override + String get updateDontRemind => 'No recordar'; + + @override + String get providerPriority => 'Prioridad del proveedor'; + + @override + String get providerPrioritySubtitle => + 'Arrastre para reordenar los proveedores de descarga'; + + @override + String get providerPriorityTitle => 'Prioridad del proveedor'; + + @override + String get providerPriorityDescription => + 'Arrastra para reordenar los proveedores de descarga. La aplicación intentará usar los proveedores de arriba hacia abajo al descargar las pistas.'; + + @override + String get providerPriorityInfo => + 'Si una pista no está disponible en el primer proveedor, la aplicación intentará automáticamente el siguiente.'; + + @override + String get providerBuiltIn => 'Integrado'; + + @override + String get providerExtension => 'Extensión'; + + @override + String get metadataProviderPriority => 'Prioridad del proveedor de metadatos'; + + @override + String get metadataProviderPrioritySubtitle => + 'Orden usado al recuperar metadatos de la pista'; + + @override + String get metadataProviderPriorityTitle => 'Prioridad de los metadatos'; + + @override + String get metadataProviderPriorityDescription => + 'Arrastra para reordenar los proveedores de metadatos. La aplicación probará los proveedores de arriba hacia abajo al buscar pistas y obtener los metadatos.'; + + @override + String get metadataProviderPriorityInfo => + 'Deezer no tiene límites de tasa y se recomienda como principal. Spotify puede valorar el límite después de muchas solicitudes.'; + + @override + String get metadataNoRateLimits => 'Sin límites de tasa'; + + @override + String get metadataMayRateLimit => 'Sin límites de tasa'; + + @override + String get logTitle => 'Registros'; + + @override + String get logCopy => 'Copiar Registros'; + + @override + String get logClear => 'Limpiar registros'; + + @override + String get logShare => 'Compartir Registros'; + + @override + String get logEmpty => 'No hay registros aún'; + + @override + String get logCopied => 'Registros copiados al portapapeles'; + + @override + String get logSearchHint => 'Buscar registros...'; + + @override + String get logFilterLevel => 'Nivel'; + + @override + String get logFilterSection => 'Filtrar'; + + @override + String get logShareLogs => 'Compartir registros'; + + @override + String get logClearLogs => 'Borrar registros'; + + @override + String get logClearLogsTitle => 'Limpiar registros'; + + @override + String get logClearLogsMessage => + '¿Estás seguro que deseas limpiar todos los registros?'; + + @override + String get logIspBlocking => 'BLOQUEO POR EL ISP DETECTADO'; + + @override + String get logRateLimited => 'TASA LIMITADA'; + + @override + String get logNetworkError => 'ERROR DE RED'; + + @override + String get logTrackNotFound => 'PISTA NO ENCONTRADA'; + + @override + String get logFilterBySeverity => 'Filtrar los registros por gravedad'; + + @override + String get logNoLogsYet => 'No hay registros aún'; + + @override + String get logNoLogsYetSubtitle => + 'Los registros aparecerán aquí mientras usas la aplicación'; + + @override + String get logIssueSummary => 'Resumen de Incidencias'; + + @override + String get logIspBlockingDescription => + 'Tu ISP puede estar bloqueando el acceso a los servicios de descarga'; + + @override + String get logIspBlockingSuggestion => + 'Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8'; + + @override + String get logRateLimitedDescription => 'Demasiadas solicitudes al servicio'; + + @override + String get logRateLimitedSuggestion => + 'Espere unos minutos antes de volver a intentarlo'; + + @override + String get logNetworkErrorDescription => 'Problemas de conexión detectados'; + + @override + String get logNetworkErrorSuggestion => 'Comprueba tu conexión a internet'; + + @override + String get logTrackNotFoundDescription => + 'No se pudieron encontrar algunas pistas en los servicios de descarga'; + + @override + String get logTrackNotFoundSuggestion => + 'La pista puede no estar disponible en calidad sin pérdida'; + + @override + String logTotalErrors(int count) { + return 'Total de errores: $count'; + } + + @override + String logAffected(String domains) { + return 'Afectado: $domains'; + } + + @override + String logEntriesFiltered(int count) { + return 'Entradas ($count filtradas)'; + } + + @override + String logEntries(int count) { + return 'Entradas ($count)'; + } + + @override + String get credentialsTitle => 'Credenciales de Spotify'; + + @override + String get credentialsDescription => + 'Introduzca su ID de cliente y secreto para utilizar su propia cuota de aplicación de Spotify.'; + + @override + String get credentialsClientId => 'ID del cliente'; + + @override + String get credentialsClientIdHint => 'Pegar ID de cliente'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Pegar Client Secret'; + + @override + String get channelStable => 'Estable'; + + @override + String get channelPreview => 'Vista previa'; + + @override + String get sectionSearchSource => 'Buscar Fuente'; + + @override + String get sectionDownload => 'Descargar'; + + @override + String get sectionPerformance => 'Alto rendimiento'; + + @override + String get sectionApp => 'Aplicación'; + + @override + String get sectionData => 'Datos'; + + @override + String get sectionDebug => 'Depuración'; + + @override + String get sectionService => 'Servicio'; + + @override + String get sectionAudioQuality => 'Calidad de Sonido'; + + @override + String get sectionFileSettings => 'Ajustes del archivo'; + + @override + String get sectionColor => 'Colores'; + + @override + String get sectionTheme => 'Tema'; + + @override + String get sectionLayout => 'Diseño'; + + @override + String get sectionLanguage => 'Idioma'; + + @override + String get appearanceLanguage => 'Idioma de la aplicación'; + + @override + String get appearanceLanguageSubtitle => 'Elija su idioma preferido'; + + @override + String get settingsAppearanceSubtitle => 'Tema, colores, pantalla'; + + @override + String get settingsDownloadSubtitle => + 'Servicio, calidad, formato del nombre del archivo'; + + @override + String get settingsOptionsSubtitle => + 'Alternativa, letras, carátula, actualizaciones'; + + @override + String get settingsExtensionsSubtitle => + 'Administrar proveedores de descarga'; + + @override + String get settingsLogsSubtitle => + 'Ver registros de aplicaciones para depuración'; + + @override + String get loadingSharedLink => 'Cargando enlace compartido...'; + + @override + String get pressBackAgainToExit => 'Presione de nuevo para salir'; + + @override + String get tracksHeader => 'Pistas'; + + @override + String downloadAllCount(int count) { + return 'Descargar Todo ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count pistas', + one: '1 pista', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copiar ruta de archivo'; + + @override + String get trackRemoveFromDevice => 'Eliminar del dispositivo'; + + @override + String get trackLoadLyrics => 'Cargar letras'; + + @override + String get trackMetadata => 'Metadatos'; + + @override + String get trackFileInfo => 'Información de archivo'; + + @override + String get trackLyrics => 'Letras'; + + @override + String get trackFileNotFound => 'Archivo no encontrado'; + + @override + String get trackOpenInDeezer => 'Abrir en Deezer'; + + @override + String get trackOpenInSpotify => 'Abrir en Spotify'; + + @override + String get trackTrackName => 'Nombre de pista'; + + @override + String get trackArtist => 'Artista'; + + @override + String get trackAlbumArtist => 'Artista del álbum'; + + @override + String get trackAlbum => 'Álbum'; + + @override + String get trackTrackNumber => 'Número de pista'; + + @override + String get trackDiscNumber => 'Número de disco'; + + @override + String get trackDuration => 'Duración'; + + @override + String get trackAudioQuality => 'Calidad del sonido'; + + @override + String get trackReleaseDate => 'Fecha de lanzamiento'; + + @override + String get trackDownloaded => 'Descargado'; + + @override + String get trackCopyLyrics => 'Copiar letras'; + + @override + String get trackLyricsNotAvailable => 'Letras no disponibles para este tema'; + + @override + String get trackLyricsTimeout => + 'Tiempo de espera agotado. Inténtalo de nuevo más tarde.'; + + @override + String get trackLyricsLoadFailed => 'Error al cargar la letra'; + + @override + String get trackCopiedToClipboard => 'Copiado al portapapeles'; + + @override + String get trackDeleteConfirmTitle => '¿Eliminar del dispositivo?'; + + @override + String get trackDeleteConfirmMessage => + 'Esto eliminará permanentemente el archivo descargado y lo eliminará de tu historial.'; + + @override + String trackCannotOpen(String message) { + return 'No se puede abrir: $message'; + } + + @override + String get dateToday => 'Hoy'; + + @override + String get dateYesterday => 'Ayer'; + + @override + String dateDaysAgo(int count) { + return 'Hace $count días'; + } + + @override + String dateWeeksAgo(int count) { + return '$count semanas antes'; + } + + @override + String dateMonthsAgo(int count) { + return '$count meses atrás'; + } + + @override + String get concurrentSequential => 'Secuencial'; + + @override + String get concurrentParallel2 => '2 simultáneamente'; + + @override + String get concurrentParallel3 => '3 simultáneamente'; + + @override + String get tapToSeeError => 'Pulse para ver los detalles del error'; + + @override + String get storeFilterAll => 'Todo'; + + @override + String get storeFilterMetadata => 'Metadatos'; + + @override + String get storeFilterDownload => 'Descargar'; + + @override + String get storeFilterUtility => 'Utilidad'; + + @override + String get storeFilterLyrics => 'Letras'; + + @override + String get storeFilterIntegration => 'Integración'; + + @override + String get storeClearFilters => 'Limpiar filtros'; + + @override + String get storeNoResults => 'No se encontraron extensiones'; + + @override + String get extensionProviderPriority => 'Prioridad del proveedor'; + + @override + String get extensionInstallButton => 'Instalar extensión'; + + @override + String get extensionDefaultProvider => 'Por defecto (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Usar búsqueda integrada'; + + @override + String get extensionAuthor => 'Autor/a'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Recursos'; + + @override + String get extensionMetadataProvider => 'Proveedor de metadatos'; + + @override + String get extensionDownloadProvider => 'Proveedor de descargas'; + + @override + String get extensionLyricsProvider => 'Proveedor de letras'; + + @override + String get extensionUrlHandler => 'Gestor de URL'; + + @override + String get extensionQualityOptions => 'Opciones de calidad'; + + @override + String get extensionPostProcessingHooks => 'Hooks post-procesamiento'; + + @override + String get extensionPermissions => 'Permisos'; + + @override + String get extensionSettings => 'Ajustes'; + + @override + String get extensionRemoveButton => 'Eliminar extensión'; + + @override + String get extensionUpdated => 'Actualizado'; + + @override + String get extensionMinAppVersion => 'Versión Mínima de la aplicación'; + + @override + String get extensionCustomTrackMatching => + 'Coincidencia de pista personalizada'; + + @override + String get extensionPostProcessing => 'Post-Procesamiento'; + + @override + String extensionHooksAvailable(int count) { + return '$count hook(s) disponibles'; + } + + @override + String extensionPatternsCount(int count) { + return 'Patrón(es) $count'; + } + + @override + String extensionStrategy(String strategy) { + return 'Estrategia: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Prioridad del proveedor'; + + @override + String get extensionsInstalledSection => 'Extensiones instaladas'; + + @override + String get extensionsNoExtensions => 'No hay extensiones instaladas'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Instalar archivos .spotiflac-ext para añadir nuevos proveedores'; + + @override + String get extensionsInstallButton => 'Instalar extensión'; + + @override + String get extensionsInfoTip => + 'Las extensiones pueden añadir nuevos metadatos y proveedores de descargas. Sólo instalar extensiones desde fuentes confiables.'; + + @override + String get extensionsInstalledSuccess => 'Extensión instalada correctamente'; + + @override + String get extensionsDownloadPriority => 'Prioridad de descarga'; + + @override + String get extensionsDownloadPrioritySubtitle => + 'Establecer orden de servicio de descarga'; + + @override + String get extensionsNoDownloadProvider => + 'No hay extensiones con proveedor de descargas'; + + @override + String get extensionsMetadataPriority => 'Prioridad de los metadatos'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Establecer orden de búsqueda y metadatos'; + + @override + String get extensionsNoMetadataProvider => + 'No hay extensiones con el proveedor de metadatos'; + + @override + String get extensionsSearchProvider => 'Proveedor de búsqueda'; + + @override + String get extensionsNoCustomSearch => + 'No hay extensiones con búsqueda personalizada'; + + @override + String get extensionsSearchProviderDescription => + 'Elegir qué servicio usar para buscar pistas'; + + @override + String get extensionsCustomSearch => 'Búsqueda personalizada'; + + @override + String get extensionsErrorLoading => 'Error al cargar la extensión'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24 bits/hasta 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24 bits / hasta 192kHz'; + + @override + String get qualityNote => + 'La calidad real depende de la disponibilidad de la pista del servicio'; + + @override + String get downloadAskBeforeDownload => 'Preguntar antes de descargar'; + + @override + String get downloadDirectory => 'Carpeta de descarga'; + + @override + String get downloadSeparateSinglesFolder => 'Carpeta separada para pistas'; + + @override + String get downloadAlbumFolderStructure => 'Estructura de carpeta del álbum'; + + @override + String get downloadSaveFormat => 'Guardar Formato'; + + @override + String get downloadSelectService => 'Seleccionar Servicio'; + + @override + String get downloadSelectQuality => 'Seleccionar Calidad'; + + @override + String get downloadFrom => 'Descargar Desde'; + + @override + String get downloadDefaultQualityLabel => 'Calidad por Defecto'; + + @override + String get downloadBestAvailable => 'La mejor disponible'; + + @override + String get folderNone => 'Ninguna'; + + @override + String get folderNoneSubtitle => + 'Guardar todos los archivos directamente para descargar la carpeta'; + + @override + String get folderArtist => 'Artista'; + + @override + String get folderArtistSubtitle => 'Nombre del Artista/nombre de archivo'; + + @override + String get folderAlbum => 'Álbum'; + + @override + String get folderAlbumSubtitle => 'Nombre del álbum/nombre de archivo'; + + @override + String get folderArtistAlbum => 'Artista/Álbum'; + + @override + String get folderArtistAlbumSubtitle => + 'Nombre del Artista/Nombre del Álbum/Nombre del Archivo'; + + @override + String get serviceTidal => 'Tidal'; + + @override + String get serviceQobuz => 'Qobuz'; + + @override + String get serviceAmazon => 'Amazon'; + + @override + String get serviceDeezer => 'Deezer'; + + @override + String get serviceSpotify => 'Spotify'; + + @override + String get appearanceAmoledDark => 'AMOLED Oscuro'; + + @override + String get appearanceAmoledDarkSubtitle => 'Fondo negro puro'; + + @override + String get appearanceChooseAccentColor => 'Elegir color principal'; + + @override + String get appearanceChooseTheme => 'Modo de tema'; + + @override + String get queueTitle => 'Descargas en proceso'; + + @override + String get queueClearAll => 'Eliminar todo'; + + @override + String get queueClearAllMessage => + '¿Estás seguro de que quieres borrar todas las descargas?'; + + @override + String get queueEmpty => 'No hay descargas en cola'; + + @override + String get queueEmptySubtitle => 'Añadir pistas desde la pantalla de inicio'; + + @override + String get queueClearCompleted => 'Limpiar tareas finalizadas'; + + @override + String get queueDownloadFailed => 'Descarga fallida'; + + @override + String get queueTrackLabel => 'Pista:'; + + @override + String get queueArtistLabel => 'Artista:'; + + @override + String get queueErrorLabel => 'Error:'; + + @override + String get queueUnknownError => 'Error desconocido'; + + @override + String get albumFolderArtistAlbum => 'Artista / Álbum'; + + @override + String get albumFolderArtistAlbumSubtitle => + 'Álbumes/Nombre del Artista/Nombre del Álbum/'; + + @override + String get albumFolderArtistYearAlbum => 'Artista / [Año] Álbum'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Álbumes/Nombre del Artista /[2005] Nombre del Álbum/'; + + @override + String get albumFolderAlbumOnly => 'Sólo álbum'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Álbumes/Nombre del Álbum/'; + + @override + String get albumFolderYearAlbum => 'Álbum [Año]'; + + @override + String get albumFolderYearAlbumSubtitle => 'Álbumes/[2005] Nombre del Álbum/'; + + @override + String get downloadedAlbumDeleteSelected => 'Borrar Seleccionados'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¿Eliminar $count $_temp0 del historial?\n\nEsto también eliminará los archivos del almacenamiento.'; + } + + @override + String get downloadedAlbumTracksHeader => 'Pistas'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count descargado'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count seleccionado'; + } + + @override + String get downloadedAlbumAllSelected => 'Todas las pistas seleccionadas'; + + @override + String get downloadedAlbumTapToSelect => 'Toca las pistas para seleccionar'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¡Eliminar $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Seleccionar pistas a eliminar'; + + @override + String get utilityFunctions => 'Funciones de utilidad'; + + @override + String get recentTypeArtist => 'Artista'; + + @override + String get recentTypeAlbum => 'Álbum'; + + @override + String get recentTypeSong => 'Canción'; + + @override + String get recentTypePlaylist => 'Lista de reproducción'; + + @override + String recentPlaylistInfo(String name) { + return 'Lista de reproducción: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 44995932..92bf6148 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -402,6 +402,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'; @@ -894,6 +897,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 +1435,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 +1576,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'; @@ -1782,6 +1828,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'; @@ -1973,6 +2035,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'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 7ada4ec3..f95470da 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -402,6 +402,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'; @@ -894,6 +897,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 +1435,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 +1576,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'; @@ -1782,6 +1828,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'; @@ -1973,6 +2035,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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 85249455..258ebad7 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -406,6 +406,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'; @@ -900,6 +903,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 +1445,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 +1586,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'; @@ -1794,6 +1840,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'; @@ -1986,6 +2048,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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index dc159b26..9dd91796 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -16,19 +16,19 @@ class AppLocalizationsJa extends AppLocalizations { 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; @override - String get navHome => 'Home'; + String get navHome => 'ホーム'; @override - String get navHistory => 'History'; + String get navHistory => '履歴'; @override - String get navSettings => 'Settings'; + String get navSettings => '設定'; @override - String get navStore => 'Store'; + String get navStore => 'ストア'; @override - String get homeTitle => 'Home'; + String get homeTitle => 'ホーム'; @override String get homeSearchHint => 'Paste Spotify URL or search...'; @@ -52,20 +52,20 @@ class AppLocalizationsJa extends AppLocalizations { @override String historyDownloading(int count) { - return 'Downloading ($count)'; + return 'ダウンロード中 ($count)'; } @override - String get historyDownloaded => 'Downloaded'; + String get historyDownloaded => 'ダウンロード済み'; @override - String get historyFilterAll => 'All'; + String get historyFilterAll => 'すべて'; @override - String get historyFilterAlbums => 'Albums'; + String get historyFilterAlbums => 'アルバム'; @override - String get historyFilterSingles => 'Singles'; + String get historyFilterSingles => 'シングル'; @override String historyTracksCount(int count) { @@ -110,25 +110,25 @@ class AppLocalizationsJa extends AppLocalizations { 'Single track downloads will appear here'; @override - String get settingsTitle => 'Settings'; + String get settingsTitle => '設定'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => 'ダウンロード'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => '外観'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => 'オプション'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => '拡張'; @override - String get settingsAbout => 'About'; + String get settingsAbout => 'アプリについて'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => 'ダウンロード'; @override String get downloadLocation => 'Download Location'; @@ -137,16 +137,16 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadLocationSubtitle => 'Choose where to save files'; @override - String get downloadLocationDefault => 'Default location'; + String get downloadLocationDefault => 'デフォルトの場所'; @override - String get downloadDefaultService => 'Default Service'; + String get downloadDefaultService => 'デフォルトのサービス'; @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + String get downloadDefaultServiceSubtitle => 'ダウンロードに使用したサービス'; @override - String get downloadDefaultQuality => 'Default Quality'; + String get downloadDefaultQuality => 'デフォルトの品質'; @override String get downloadAskQuality => 'Ask Quality Before Download'; @@ -156,7 +156,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Show quality picker for each download'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => 'ファイル名の形式'; @override String get downloadFolderOrganization => 'Folder Organization'; @@ -181,46 +181,46 @@ class AppLocalizationsJa extends AppLocalizations { String get quality128 => '128 kbps'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => '外観'; @override - String get appearanceTheme => 'Theme'; + String get appearanceTheme => 'テーマ'; @override - String get appearanceThemeSystem => 'System'; + String get appearanceThemeSystem => 'システム'; @override - String get appearanceThemeLight => 'Light'; + String get appearanceThemeLight => 'ライト'; @override - String get appearanceThemeDark => 'Dark'; + String get appearanceThemeDark => 'ダーク'; @override - String get appearanceDynamicColor => 'Dynamic Color'; + String get appearanceDynamicColor => 'ダイナミックカラー'; @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; @override - String get appearanceAccentColor => 'Accent Color'; + String get appearanceAccentColor => 'アクセントカラー'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => '履歴の表示'; @override - String get appearanceHistoryViewList => 'List'; + String get appearanceHistoryViewList => 'リスト'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'グリッド'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => 'オプション'; @override - String get optionsSearchSource => 'Search Source'; + String get optionsSearchSource => '検索ソース'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => 'プライマリーのプロバイダー'; @override String get optionsPrimaryProviderSubtitle => @@ -228,7 +228,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return '拡張の使用: $extensionName'; } @override @@ -243,23 +243,23 @@ class AppLocalizationsJa extends AppLocalizations { 'Try other services if download fails'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する'; @override String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => '歌詞を埋め込む'; @override String get optionsEmbedLyricsSubtitle => 'Embed synced lyrics into FLAC files'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => '最大品質のカバー'; @override String get optionsMaxQualityCoverSubtitle => @@ -281,26 +281,26 @@ class AppLocalizationsJa extends AppLocalizations { 'Parallel downloads may trigger rate limiting'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => '拡張ストア'; @override String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => '更新を確認'; @override String get optionsCheckUpdatesSubtitle => 'Notify when new version is available'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => '更新チャンネル'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => '安定版リリースのみ'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => 'プレビューリリースを入手'; @override String get optionsUpdateChannelWarning => @@ -323,11 +323,11 @@ class AppLocalizationsJa extends AppLocalizations { String get optionsDetailedLoggingOff => 'Enable for bug reports'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Spotify の認証情報'; @override String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; + return 'クライアント ID: $clientId...'; } @override @@ -338,62 +338,62 @@ class AppLocalizationsJa extends AppLocalizations { 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => '拡張'; @override - String get extensionsInstalled => 'Installed Extensions'; + String get extensionsInstalled => 'インストール済みの拡張'; @override - String get extensionsNone => 'No extensions installed'; + String get extensionsNone => '拡張はインストールされていません'; @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + String get extensionsNoneSubtitle => 'ストアタブから拡張をインストール'; @override - String get extensionsEnabled => 'Enabled'; + String get extensionsEnabled => '有効'; @override String get extensionsDisabled => 'Disabled'; @override String extensionsVersion(String version) { - return 'Version $version'; + return 'バージョン $version'; } @override String extensionsAuthor(String author) { - return 'by $author'; + return '作者 $author'; } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => 'アンインストール'; @override - String get extensionsSetAsSearch => 'Set as Search Provider'; + String get extensionsSetAsSearch => '検索プロバイダーを設定'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => '拡張ストア'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => '拡張を検索...'; @override - String get storeInstall => 'Install'; + String get storeInstall => 'インストール'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => 'インストール済み'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => '更新'; @override - String get aboutTitle => 'About'; + String get aboutTitle => 'アプリについて'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => '貢献者'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => 'モバイルバージョンの開発者'; @override String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; @@ -403,25 +403,28 @@ class AppLocalizationsJa extends AppLocalizations { 'The talented artist who created our beautiful app logo!'; @override - String get aboutSpecialThanks => 'Special Thanks'; + String get aboutTranslators => 'Translators'; @override - String get aboutLinks => 'Links'; + String get aboutSpecialThanks => 'スペシャルサンクス'; @override - String get aboutMobileSource => 'Mobile source code'; + String get aboutLinks => 'リンク'; @override - String get aboutPCSource => 'PC source code'; + String get aboutMobileSource => 'モバイル版のソースコード'; @override - String get aboutReportIssue => 'Report an issue'; + String get aboutPCSource => 'PC 版のソースコード'; + + @override + String get aboutReportIssue => 'Issue で報告する'; @override String get aboutReportIssueSubtitle => 'Report any problems you encounter'; @override - String get aboutFeatureRequest => 'Feature request'; + String get aboutFeatureRequest => '機能の要望'; @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; @@ -430,16 +433,16 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutSupport => 'Support'; @override - String get aboutBuyMeCoffee => 'Buy me a coffee'; + String get aboutBuyMeCoffee => 'コーヒーを買ってください'; @override - String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします'; @override - String get aboutApp => 'App'; + String get aboutApp => 'アプリ'; @override - String get aboutVersion => 'Version'; + String get aboutVersion => 'バージョン'; @override String get aboutBinimumDesc => @@ -497,10 +500,10 @@ class AppLocalizationsJa extends AppLocalizations { String get artistAlbums => 'Albums'; @override - String get artistSingles => 'Singles & EPs'; + String get artistSingles => 'シングルと EP'; @override - String get artistCompilations => 'Compilations'; + String get artistCompilations => 'コンピレーション'; @override String artistReleases(int count) { @@ -589,13 +592,13 @@ class AppLocalizationsJa extends AppLocalizations { String get setupChooseFolder => 'Choose Folder'; @override - String get setupContinue => 'Continue'; + String get setupContinue => '続行'; @override - String get setupSkip => 'Skip for now'; + String get setupSkip => '今はスキップ'; @override - String get setupStorageAccessRequired => 'Storage Access Required'; + String get setupStorageAccessRequired => 'ストレージアクセスが必要です'; @override String get setupStorageAccessMessage => @@ -675,7 +678,7 @@ class AppLocalizationsJa extends AppLocalizations { String get setupStepSpotify => 'Spotify'; @override - String get setupStepPermission => 'Permission'; + String get setupStepPermission => '権限'; @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -691,14 +694,14 @@ class AppLocalizationsJa extends AppLocalizations { String get setupNotificationGranted => 'Notification Permission Granted!'; @override - String get setupNotificationEnable => 'Enable Notifications'; + String get setupNotificationEnable => '通知を有効化する'; @override String get setupNotificationDescription => 'Get notified when downloads complete or require attention.'; @override - String get setupFolderSelected => 'Download Folder Selected!'; + String get setupFolderSelected => 'ダウンロードフォルダが選択済みです!'; @override String get setupFolderChoose => 'Choose Download Folder'; @@ -714,26 +717,26 @@ class AppLocalizationsJa extends AppLocalizations { String get setupSelectFolder => 'Select Folder'; @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; + String get setupSpotifyApiOptional => 'Spotify API (任意)'; @override String get setupSpotifyApiDescription => 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; @override - String get setupUseSpotifyApi => 'Use Spotify API'; + String get setupUseSpotifyApi => 'Spotify API を使用する'; @override String get setupEnterCredentialsBelow => 'Enter your credentials below'; @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; + String get setupUsingDeezer => 'Deezer を使用中 (アカウントは不要です)'; @override - String get setupEnterClientId => 'Enter Spotify Client ID'; + String get setupEnterClientId => 'Spotify クライアント ID を入力'; @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; + String get setupEnterClientSecret => 'Spotify クライアントシークレットを入力'; @override String get setupGetFreeCredentials => @@ -754,19 +757,19 @@ class AppLocalizationsJa extends AppLocalizations { 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @override - String get setupSkipForNow => 'Skip for now'; + String get setupSkipForNow => '今はスキップ'; @override - String get setupBack => 'Back'; + String get setupBack => '戻る'; @override - String get setupNext => 'Next'; + String get setupNext => '次へ'; @override String get setupGetStarted => 'Get Started'; @override - String get setupSkipAndStart => 'Skip & Start'; + String get setupSkipAndStart => 'スキップと開始'; @override String get setupAllowAccessToManageFiles => @@ -858,7 +861,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Are you sure you want to remove this extension? This cannot be undone.'; @override - String get dialogUninstallExtension => 'Uninstall Extension?'; + String get dialogUninstallExtension => '拡張をアンインストールしますか?'; @override String dialogUninstallExtensionMessage(String extensionName) { @@ -887,13 +890,18 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get dialogImportPlaylistTitle => 'Import Playlist'; + String get dialogImportPlaylistTitle => 'プレイリストをインポート'; @override String dialogImportPlaylistMessage(int count) { 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'; @@ -980,7 +988,7 @@ class AppLocalizationsJa extends AppLocalizations { String get snackbarFailedToUpdate => 'Failed to update extension'; @override - String get errorRateLimited => 'Rate Limited'; + String get errorRateLimited => 'レート制限'; @override String get errorRateLimitedMessage => @@ -1178,7 +1186,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get updateDownload => 'Download'; + String get updateDownload => 'ダウンロード'; @override String get updateLater => 'Later'; @@ -1199,7 +1207,7 @@ class AppLocalizationsJa extends AppLocalizations { String get updateNewVersionReady => 'A new version is ready'; @override - String get updateCurrent => 'Current'; + String get updateCurrent => '現在'; @override String get updateNew => 'New'; @@ -1303,13 +1311,13 @@ class AppLocalizationsJa extends AppLocalizations { String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; + String get logIspBlocking => 'ISP のブロックを検出しました'; @override - String get logRateLimited => 'RATE LIMITED'; + String get logRateLimited => 'レート制限'; @override - String get logNetworkError => 'NETWORK ERROR'; + String get logNetworkError => 'ネットワークエラー'; @override String get logTrackNotFound => 'TRACK NOT FOUND'; @@ -1427,6 +1435,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'; @@ -1498,22 +1535,22 @@ class AppLocalizationsJa extends AppLocalizations { String get trackMetadata => 'Metadata'; @override - String get trackFileInfo => 'File Info'; + String get trackFileInfo => 'ファイル情報'; @override - String get trackLyrics => 'Lyrics'; + String get trackLyrics => '歌詞'; @override - String get trackFileNotFound => 'File not found'; + String get trackFileNotFound => 'ファイルがありません'; @override - String get trackOpenInDeezer => 'Open in Deezer'; + String get trackOpenInDeezer => 'Deezer で開く'; @override - String get trackOpenInSpotify => 'Open in Spotify'; + String get trackOpenInSpotify => 'Spotify で開く'; @override - String get trackTrackName => 'Track name'; + String get trackTrackName => 'トラック名'; @override String get trackArtist => 'Artist'; @@ -1539,6 +1576,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'; @@ -1636,16 +1682,16 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; + String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する'; @override - String get extensionAuthor => 'Author'; + String get extensionAuthor => '作者'; @override String get extensionId => 'ID'; @override - String get extensionError => 'Error'; + String get extensionError => 'エラー'; @override String get extensionCapabilities => 'Capabilities'; @@ -1675,16 +1721,16 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionSettings => 'Settings'; @override - String get extensionRemoveButton => 'Remove Extension'; + String get extensionRemoveButton => '拡張を削除'; @override - String get extensionUpdated => 'Updated'; + String get extensionUpdated => '更新済み'; @override - String get extensionMinAppVersion => 'Min App Version'; + String get extensionMinAppVersion => '最小のアプリバージョン'; @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; + String get extensionCustomTrackMatching => 'カスタムトラックマッチング'; @override String get extensionPostProcessing => 'Post-Processing'; @@ -1708,17 +1754,17 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionsProviderPrioritySection => 'Provider Priority'; @override - String get extensionsInstalledSection => 'Installed Extensions'; + String get extensionsInstalledSection => 'インストール済みの拡張'; @override - String get extensionsNoExtensions => 'No extensions installed'; + String get extensionsNoExtensions => '拡張はインストールされていません'; @override String get extensionsNoExtensionsSubtitle => 'Install .spotiflac-ext files to add new providers'; @override - String get extensionsInstallButton => 'Install Extension'; + String get extensionsInstallButton => '拡張をインストール'; @override String get extensionsInfoTip => @@ -1765,22 +1811,38 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionsErrorLoading => 'Error loading extension'; @override - String get qualityFlacLossless => 'FLAC Lossless'; + String get qualityFlacLossless => 'FLAC ロスレス'; @override String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; @override - String get qualityHiResFlac => 'Hi-Res FLAC'; + String get qualityHiResFlac => 'ハイレゾ FLAC'; @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + String get qualityHiResFlacSubtitle => '24-bit / 最大 96kHz'; @override - String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + String get qualityHiResFlacMax => 'ハイレゾ FLAC 最大'; @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + 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 => @@ -1790,10 +1852,10 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadAskBeforeDownload => 'Ask Before Download'; @override - String get downloadDirectory => 'Download Directory'; + String get downloadDirectory => 'ダウンロードディレクトリ'; @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + String get downloadSeparateSinglesFolder => 'シングルのフォルダを分割'; @override String get downloadAlbumFolderStructure => 'Album Folder Structure'; @@ -1856,22 +1918,22 @@ class AppLocalizationsJa extends AppLocalizations { String get serviceSpotify => 'Spotify'; @override - String get appearanceAmoledDark => 'AMOLED Dark'; + String get appearanceAmoledDark => 'AMOLED ダーク'; @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; + String get appearanceAmoledDarkSubtitle => 'ピュアブラックの背景'; @override String get appearanceChooseAccentColor => 'Choose Accent Color'; @override - String get appearanceChooseTheme => 'Theme Mode'; + String get appearanceChooseTheme => 'テーマモード'; @override - String get queueTitle => 'Download Queue'; + String get queueTitle => 'ダウンロードキュー'; @override - String get queueClearAll => 'Clear All'; + String get queueClearAll => 'すべて消去'; @override String get queueClearAllMessage => @@ -1973,6 +2035,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'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 372a765f..4b3487af 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -402,6 +402,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'; @@ -894,6 +897,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 +1435,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 +1576,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'; @@ -1782,6 +1828,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'; @@ -1973,6 +2035,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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index f3fb6361..67086594 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -402,6 +402,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'; @@ -894,6 +897,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 +1435,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 +1576,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'; @@ -1782,6 +1828,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'; @@ -1973,6 +2035,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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 8e985b89..42c9e1c6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -402,6 +402,9 @@ class AppLocalizationsPt 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'; @@ -894,6 +897,11 @@ class AppLocalizationsPt 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 +1435,35 @@ class AppLocalizationsPt 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 +1576,15 @@ class AppLocalizationsPt 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'; @@ -1782,6 +1828,22 @@ class AppLocalizationsPt 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'; @@ -1973,6 +2035,2027 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + + @override + String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } +} + +/// The translations for Portuguese, as used in Portugal (`pt_PT`). +class AppLocalizationsPtPt extends AppLocalizationsPt { + AppLocalizationsPtPt() : super('pt_PT'); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get appDescription => + 'Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.'; + + @override + String get navHome => 'Início'; + + @override + String get navHistory => 'Histórico'; + + @override + String get navSettings => 'Configurações'; + + @override + String get navStore => 'Loja'; + + @override + String get homeTitle => 'Início'; + + @override + String get homeSearchHint => 'Pesquise ou cole a URL do Spotify...'; + + @override + String homeSearchHintExtension(String extensionName) { + return 'Pesquisar com $extensionName...'; + } + + @override + String get homeSubtitle => 'Cole um link do Spotify ou procure por nome'; + + @override + String get homeSupports => + 'Suporte: Faixas, Álbuns, Playlists, URLs de Artista'; + + @override + String get homeRecent => 'Recentes'; + + @override + String get historyTitle => 'Histórico'; + + @override + String historyDownloading(int count) { + return 'Baixando ($count)'; + } + + @override + String get historyDownloaded => 'Baixados'; + + @override + String get historyFilterAll => 'Tudo'; + + @override + String get historyFilterAlbums => 'Álbuns'; + + @override + String get historyFilterSingles => 'Singles'; + + @override + String historyTracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count faixas', + one: '1 faixa', + ); + return '$_temp0'; + } + + @override + String historyAlbumsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count álbuns', + one: '1 álbum', + ); + return '$_temp0'; + } + + @override + String get historyNoDownloads => 'Nenhum histórico de downloads'; + + @override + String get historyNoDownloadsSubtitle => 'As faixas baixadas aparecerão aqui'; + + @override + String get historyNoAlbums => 'Sem álbuns baixados'; + + @override + String get historyNoAlbumsSubtitle => + 'Baixe várias faixas de um álbum para vê-las aqui'; + + @override + String get historyNoSingles => 'Sem singles baixados'; + + @override + String get historyNoSinglesSubtitle => + 'Os downloads de faixa individuais aparecerão aqui'; + + @override + String get settingsTitle => 'Configurações'; + + @override + String get settingsDownload => 'Download'; + + @override + String get settingsAppearance => 'Aparência'; + + @override + String get settingsOptions => 'Opções'; + + @override + String get settingsExtensions => 'Extensões'; + + @override + String get settingsAbout => 'Sobre'; + + @override + String get downloadTitle => 'Download'; + + @override + String get downloadLocation => 'Local dos Downloads'; + + @override + String get downloadLocationSubtitle => 'Escolha onde salvar os arquivos'; + + @override + String get downloadLocationDefault => 'Local padrão'; + + @override + String get downloadDefaultService => 'Serviço Padrão'; + + @override + String get downloadDefaultServiceSubtitle => 'Serviço usado para downloads'; + + @override + String get downloadDefaultQuality => 'Qualidade Predefinida'; + + @override + String get downloadAskQuality => 'Perguntar qualidade antes de baixar'; + + @override + String get downloadAskQualitySubtitle => + 'Mostrar seletor de qualidade para cada download'; + + @override + String get downloadFilenameFormat => 'Formato do Nome do Arquivo'; + + @override + String get downloadFolderOrganization => 'Organização de Pastas'; + + @override + String get downloadSeparateSingles => 'Separar Singles'; + + @override + String get downloadSeparateSinglesSubtitle => + 'Colocar singles numa pasta separada'; + + @override + String get qualityBest => 'Melhor Disponível'; + + @override + String get qualityFlac => 'FLAC'; + + @override + String get quality320 => '320 kbps'; + + @override + String get quality128 => '128 kbps'; + + @override + String get appearanceTitle => 'Aparência'; + + @override + String get appearanceTheme => 'Tema'; + + @override + String get appearanceThemeSystem => 'Sistema'; + + @override + String get appearanceThemeLight => 'Claro'; + + @override + String get appearanceThemeDark => 'Escuro'; + + @override + String get appearanceDynamicColor => 'Cores Dinâmicas'; + + @override + String get appearanceDynamicColorSubtitle => + 'Usar cores do seu papel de parede'; + + @override + String get appearanceAccentColor => 'Cor de Destaque'; + + @override + String get appearanceHistoryView => 'Visualização do Histórico'; + + @override + String get appearanceHistoryViewList => 'Lista'; + + @override + String get appearanceHistoryViewGrid => 'Grade'; + + @override + String get optionsTitle => 'Opções'; + + @override + String get optionsSearchSource => 'Origem da Pesquisa'; + + @override + String get optionsPrimaryProvider => 'Provedor Primário'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Serviço usado ao pesquisar por nome da faixa.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Usando a extensão: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Toque no Deezer ou Spotify para alternar de volta da extensão'; + + @override + String get optionsAutoFallback => 'Fallback Automático'; + + @override + String get optionsAutoFallbackSubtitle => + 'Tentar outros serviços se o download falhar'; + + @override + String get optionsUseExtensionProviders => 'Usar Provedores de Extensão'; + + @override + String get optionsUseExtensionProvidersOn => + 'Extensões serão tentadas primeiro'; + + @override + String get optionsUseExtensionProvidersOff => + 'Usando apenas provedores integrados'; + + @override + String get optionsEmbedLyrics => 'Incorporar Letras'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Incorporar letras sincronizadas aos arquivos FLAC'; + + @override + String get optionsMaxQualityCover => 'Capa de Qualidade Máxima'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Baixar capa do álbum com a mais alta resolução'; + + @override + String get optionsConcurrentDownloads => 'Downloads Simultâneos'; + + @override + String get optionsConcurrentSequential => 'Sequencial (1 por vez)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count downloads paralelos'; + } + + @override + String get optionsConcurrentWarning => + 'Downloads simultâneos podem causar um limite da taxa (ratelimit)'; + + @override + String get optionsExtensionStore => 'Loja de Extensões'; + + @override + String get optionsExtensionStoreSubtitle => + 'Mostrar aba da Loja na navegação'; + + @override + String get optionsCheckUpdates => 'Procurar Atualizações'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notificar quando uma nova versão estiver disponível'; + + @override + String get optionsUpdateChannel => 'Canal de Atualização'; + + @override + String get optionsUpdateChannelStable => 'Somente versões estáveis'; + + @override + String get optionsUpdateChannelPreview => 'Obter versões de prévia'; + + @override + String get optionsUpdateChannelWarning => + 'A prévia pode conter erros ou recursos incompletos'; + + @override + String get optionsClearHistory => 'Limpar Histórico de Download'; + + @override + String get optionsClearHistorySubtitle => + 'Remover todas as faixas baixadas do histórico'; + + @override + String get optionsDetailedLogging => 'Registro detalhado'; + + @override + String get optionsDetailedLoggingOn => + 'Registros detalhados estão sendo gravados'; + + @override + String get optionsDetailedLoggingOff => 'Habilitar para relatórios de erros'; + + @override + String get optionsSpotifyCredentials => 'Credenciais do Spotify'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'Client ID: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => + 'Obrigatório - toque para configurar'; + + @override + String get optionsSpotifyWarning => + 'O Spotify requer as suas próprias credenciais de API. Consiga gratuitamente em developer.spotify.com'; + + @override + String get extensionsTitle => 'Extensões'; + + @override + String get extensionsInstalled => 'Extensões Instaladas'; + + @override + String get extensionsNone => 'Nenhuma extensão instalada'; + + @override + String get extensionsNoneSubtitle => + 'Instalar extensões a partir da aba Loja'; + + @override + String get extensionsEnabled => 'Habilitado'; + + @override + String get extensionsDisabled => 'Desabilitado'; + + @override + String extensionsVersion(String version) { + return 'Versão $version'; + } + + @override + String extensionsAuthor(String author) { + return 'por $author'; + } + + @override + String get extensionsUninstall => 'Desinstalar'; + + @override + String get extensionsSetAsSearch => 'Definir como Provedor de Pesquisa'; + + @override + String get storeTitle => 'Loja de Extensões'; + + @override + String get storeSearch => 'Pesquisar extensões...'; + + @override + String get storeInstall => 'Instalar'; + + @override + String get storeInstalled => 'Instalado'; + + @override + String get storeUpdate => 'Atualizar'; + + @override + String get aboutTitle => 'Sobre'; + + @override + String get aboutContributors => 'Colaboradores'; + + @override + String get aboutMobileDeveloper => 'Desenvolvedor da versão móvel'; + + @override + String get aboutOriginalCreator => 'Criador do SpotiFLAC original'; + + @override + String get aboutLogoArtist => + 'O artista talentoso que criou o nosso lindo logotipo do aplicativo!'; + + @override + String get aboutSpecialThanks => 'Agradecimentos Especiais'; + + @override + String get aboutLinks => 'Links'; + + @override + String get aboutMobileSource => 'Código-fonte do app móvel'; + + @override + String get aboutPCSource => 'Código-fonte do app desktop'; + + @override + String get aboutReportIssue => 'Reportar um problema'; + + @override + String get aboutReportIssueSubtitle => + 'Reporte qualquer problema que encontrar'; + + @override + String get aboutFeatureRequest => 'Solicitação de recurso'; + + @override + String get aboutFeatureRequestSubtitle => + 'Sugira novos recursos para o aplicativo'; + + @override + String get aboutSupport => 'Apoiar'; + + @override + String get aboutBuyMeCoffee => 'Compre-me um café'; + + @override + String get aboutBuyMeCoffeeSubtitle => 'Apoie o desenvolvimento na Ko-fi'; + + @override + String get aboutApp => 'Aplicativo'; + + @override + String get aboutVersion => 'Versão'; + + @override + String get aboutBinimumDesc => + 'O criador da API QQDL e HiFi. Sem esta API, os downloads Tidal não existiriam!'; + + @override + String get aboutSachinsenalDesc => + 'O criador original do projeto HiFi. A base da integração do Tidal!'; + + @override + String get aboutDoubleDouble => 'DoubleDouble'; + + @override + String get aboutDoubleDoubleDesc => + 'API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!'; + + @override + String get aboutDabMusic => 'DAB Music'; + + @override + String get aboutDabMusicDesc => + 'A melhor API de streaming do Qobuz. Downloads de alta resolução não seriam possíveis sem isso!'; + + @override + String get aboutAppDescription => + 'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.'; + + @override + String get albumTitle => 'Álbum'; + + @override + String albumTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count faixas', + one: '1 faixa', + ); + return '$_temp0'; + } + + @override + String get albumDownloadAll => 'Baixar Tudo'; + + @override + String get albumDownloadRemaining => 'Downloads Restantes'; + + @override + String get playlistTitle => 'Playlist'; + + @override + String get artistTitle => 'Artista'; + + @override + String get artistAlbums => 'Álbuns'; + + @override + String get artistSingles => 'Singles e EPs'; + + @override + String get artistCompilations => 'Compilações'; + + @override + String artistReleases(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lançamentos', + one: '1 lançamento', + ); + return '$_temp0'; + } + + @override + String get artistPopular => 'Populares'; + + @override + String artistMonthlyListeners(String count) { + return '$count ouvintes mensais'; + } + + @override + String get trackMetadataTitle => 'Informações da Faixa'; + + @override + String get trackMetadataArtist => 'Artista'; + + @override + String get trackMetadataAlbum => 'Álbum'; + + @override + String get trackMetadataDuration => 'Duração'; + + @override + String get trackMetadataQuality => 'Qualidade'; + + @override + String get trackMetadataPath => 'Caminho do Arquivo'; + + @override + String get trackMetadataDownloadedAt => 'Baixado'; + + @override + String get trackMetadataService => 'Serviço'; + + @override + String get trackMetadataPlay => 'Reproduzir'; + + @override + String get trackMetadataShare => 'Compartilhar'; + + @override + String get trackMetadataDelete => 'Apagar'; + + @override + String get trackMetadataRedownload => 'Baixar Novamente'; + + @override + String get trackMetadataOpenFolder => 'Abrir Pasta'; + + @override + String get setupTitle => 'Bem-vindo ao SpotiFLAC'; + + @override + String get setupSubtitle => 'Vamos começar'; + + @override + String get setupStoragePermission => 'Permissão de Armazenamento'; + + @override + String get setupStoragePermissionSubtitle => + 'Necessária para salvar arquivos baixados'; + + @override + String get setupStoragePermissionGranted => 'Permissão concedida'; + + @override + String get setupStoragePermissionDenied => 'Permissão negada'; + + @override + String get setupGrantPermission => 'Conceder Permissão'; + + @override + String get setupDownloadLocation => 'Local do Download'; + + @override + String get setupChooseFolder => 'Selecionar Pasta'; + + @override + String get setupContinue => 'Continuar'; + + @override + String get setupSkip => 'Ignorar por enquanto'; + + @override + String get setupStorageAccessRequired => 'Acesso ao Armazenamento Necessário'; + + @override + String get setupStorageAccessMessage => + 'O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.'; + + @override + String get setupOpenSettings => 'Abrir Configurações'; + + @override + String get setupPermissionDeniedMessage => + 'Permissão negada. Por favor, conceda todas as permissões para continuar.'; + + @override + String setupPermissionRequired(String permissionType) { + return 'Permissão $permissionType Necessária'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return 'A permissão $permissionType é necessária para a melhor experiência. Você pode alterar isso mais tarde em Configurações.'; + } + + @override + String get setupSelectDownloadFolder => 'Escolher Pasta de Download'; + + @override + String get setupUseDefaultFolder => 'Usar Pasta Padrão?'; + + @override + String get setupNoFolderSelected => + 'Nenhuma pasta selecionada. Você gostaria de usar a pasta padrão de música?'; + + @override + String get setupUseDefault => 'Usar Padrão'; + + @override + String get setupDownloadLocationTitle => 'Local do Download'; + + @override + String get setupDownloadLocationIosMessage => + 'No iOS, downloads são salvos na pasta Documentos do aplicativo. Você pode acessá-los através do app Arquivos.'; + + @override + String get setupAppDocumentsFolder => 'Pasta Documentos do App'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recomendado - acessível através do aplicativo Arquivos'; + + @override + String get setupChooseFromFiles => 'Escolher dos Arquivos'; + + @override + String get setupChooseFromFilesSubtitle => + 'Selecione o iCloud ou outro local'; + + @override + String get setupIosEmptyFolderWarning => + 'Limitação do iOS: Pastas vazias não podem ser selecionadas. Escolha uma pasta com pelo menos um arquivo.'; + + @override + String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + + @override + String get setupStepStorage => 'Storage'; + + @override + String get setupStepNotification => 'Notification'; + + @override + String get setupStepFolder => 'Folder'; + + @override + String get setupStepSpotify => 'Spotify'; + + @override + String get setupStepPermission => 'Permission'; + + @override + String get setupStorageGranted => 'Storage Permission Granted!'; + + @override + String get setupStorageRequired => 'Storage Permission Required'; + + @override + String get setupStorageDescription => + 'SpotiFLAC needs storage permission to save your downloaded music files.'; + + @override + String get setupNotificationGranted => 'Permissão de Notificações Concedida!'; + + @override + String get setupNotificationEnable => 'Habilitar Notificações'; + + @override + String get setupNotificationDescription => + 'Seja notificado quando os downloads completarem ou exigirem atenção.'; + + @override + String get setupFolderSelected => 'Pasta para Download Selecionada!'; + + @override + String get setupFolderChoose => 'Escolher Pasta de Download'; + + @override + String get setupFolderDescription => + 'Selecione uma pasta onde as suas músicas baixadas serão salvas.'; + + @override + String get setupChangeFolder => 'Alterar Pasta'; + + @override + String get setupSelectFolder => 'Seleccionar Pasta'; + + @override + String get setupSpotifyApiOptional => 'API do Spotify (opcional)'; + + @override + String get setupSpotifyApiDescription => + 'Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.'; + + @override + String get setupUseSpotifyApi => 'Usar API do Spotify'; + + @override + String get setupEnterCredentialsBelow => 'Insira as suas credenciais abaixo'; + + @override + String get setupUsingDeezer => 'Usando o Deezer (nenhuma conta necessária)'; + + @override + String get setupEnterClientId => 'Insira o Spotify Client ID'; + + @override + String get setupEnterClientSecret => 'Insira o Spotify Client Secret'; + + @override + String get setupGetFreeCredentials => + 'Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.'; + + @override + String get setupEnableNotifications => 'Habilitar Notificações'; + + @override + String get setupProceedToNextStep => + 'Você já pode prosseguir para o próximo passo.'; + + @override + String get setupNotificationProgressDescription => + 'Você receberá notificações de progresso dos downloads.'; + + @override + String get setupNotificationBackgroundDescription => + 'Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.'; + + @override + String get setupSkipForNow => 'Ignorar por enquanto'; + + @override + String get setupBack => 'Voltar'; + + @override + String get setupNext => 'Próximo'; + + @override + String get setupGetStarted => 'Começar'; + + @override + String get setupSkipAndStart => 'Ignorar e Iniciar'; + + @override + String get setupAllowAccessToManageFiles => + 'Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.'; + + @override + String get setupGetCredentialsFromSpotify => + 'Obter credenciais do developer.spotify.com'; + + @override + String get dialogCancel => 'Cancelar'; + + @override + String get dialogOk => 'OK'; + + @override + String get dialogSave => 'Salvar'; + + @override + String get dialogDelete => 'Apagar'; + + @override + String get dialogRetry => 'Tentar novamente'; + + @override + String get dialogClose => 'Fechar'; + + @override + String get dialogYes => 'Sim'; + + @override + String get dialogNo => 'Não'; + + @override + String get dialogClear => 'Limpar'; + + @override + String get dialogConfirm => 'Confirmar'; + + @override + String get dialogDone => 'Concluído'; + + @override + String get dialogImport => 'Importar'; + + @override + String get dialogDiscard => 'Descartar'; + + @override + String get dialogRemove => 'Remover'; + + @override + String get dialogUninstall => 'Desinstalar'; + + @override + String get dialogDiscardChanges => 'Descartar Alterações?'; + + @override + String get dialogUnsavedChanges => + 'Você tem alterações não salvas. Deseja descartá-las?'; + + @override + String get dialogDownloadFailed => 'Download Falhou'; + + @override + String get dialogTrackLabel => 'Faixa:'; + + @override + String get dialogArtistLabel => 'Artista:'; + + @override + String get dialogErrorLabel => 'Erro:'; + + @override + String get dialogClearAll => 'Limpar Tudo'; + + @override + String get dialogClearAllDownloads => + 'Você tem certeza que deseja limpar todos os downloads?'; + + @override + String get dialogRemoveFromDevice => 'Remove from device?'; + + @override + String get dialogRemoveExtension => 'Remove Extension'; + + @override + String get dialogRemoveExtensionMessage => + 'Are you sure you want to remove this extension? This cannot be undone.'; + + @override + String get dialogUninstallExtension => 'Uninstall Extension?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return 'Are you sure you want to remove $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Clear History'; + + @override + String get dialogClearHistoryMessage => + 'Are you sure you want to clear all download history? This cannot be undone.'; + + @override + String get dialogDeleteSelectedTitle => 'Delete Selected'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + } + + @override + String get dialogImportPlaylistTitle => 'Import Playlist'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Found $count tracks in CSV. Add them to download queue?'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Added \"$trackName\" to queue'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" already downloaded'; + } + + @override + String get snackbarHistoryCleared => 'History cleared'; + + @override + String get snackbarCredentialsSaved => 'Credentials saved'; + + @override + String get snackbarCredentialsCleared => 'Credentials cleared'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Deleted $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'Cannot open file: $error'; + } + + @override + String get snackbarFillAllFields => 'Please fill all fields'; + + @override + String get snackbarViewQueue => 'View Queue'; + + @override + String snackbarFailedToLoad(String error) { + return 'Failed to load: $error'; + } + + @override + String snackbarUrlCopied(String platform) { + return '$platform URL copied to clipboard'; + } + + @override + String get snackbarFileNotFound => 'File not found'; + + @override + String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + + @override + String get snackbarProviderPrioritySaved => 'Provider priority saved'; + + @override + String get snackbarMetadataProviderSaved => + 'Metadata provider priority saved'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName installed.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName updated.'; + } + + @override + String get snackbarFailedToInstall => 'Failed to install extension'; + + @override + String get snackbarFailedToUpdate => 'Failed to update extension'; + + @override + String get errorRateLimited => 'Rate Limited'; + + @override + String get errorRateLimitedMessage => + 'Too many requests. Please wait a moment before searching again.'; + + @override + String errorFailedToLoad(String item) { + return 'Failed to load $item'; + } + + @override + String get errorNoTracksFound => 'No tracks found'; + + @override + String errorMissingExtensionSource(String item) { + return 'Cannot load $item: missing extension source'; + } + + @override + String get statusQueued => 'Queued'; + + @override + String get statusDownloading => 'Downloading'; + + @override + String get statusFinalizing => 'Finalizing'; + + @override + String get statusCompleted => 'Completed'; + + @override + String get statusFailed => 'Failed'; + + @override + String get statusSkipped => 'Ignorado'; + + @override + String get statusPaused => 'Pausado'; + + @override + String get actionPause => 'Pausar'; + + @override + String get actionResume => 'Retomar'; + + @override + String get actionCancel => 'Cancelar'; + + @override + String get actionStop => 'Parar'; + + @override + String get actionSelect => 'Selecionar'; + + @override + String get actionSelectAll => 'Selecionar Tudo'; + + @override + String get actionDeselect => 'Desselecionar'; + + @override + String get actionPaste => 'Colar'; + + @override + String get actionImportCsv => 'Importar CSV'; + + @override + String get actionRemoveCredentials => 'Remover Credenciais'; + + @override + String get actionSaveCredentials => 'Salvar Credenciais'; + + @override + String selectionSelected(int count) { + return '$count selecionado(s)'; + } + + @override + String get selectionAllSelected => 'Todas as faixas selecionadas'; + + @override + String get selectionTapToSelect => 'Toque nas faixas para selecionar'; + + @override + String selectionDeleteTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'faixas', + one: 'faixa', + ); + return 'Apagar $count $_temp0'; + } + + @override + String get selectionSelectToDelete => 'Selecione as faixas para apagar'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Buscando metadados... $current/$total'; + } + + @override + String get progressReadingCsv => 'Lendo CSV...'; + + @override + String get searchSongs => 'Músicas'; + + @override + String get searchArtists => 'Artistas'; + + @override + String get searchAlbums => 'Álbuns'; + + @override + String get searchPlaylists => 'Playlists'; + + @override + String get tooltipPlay => 'Reproduzir'; + + @override + String get tooltipCancel => 'Cancelar'; + + @override + String get tooltipStop => 'Parar'; + + @override + String get tooltipRetry => 'Tentar Novamente'; + + @override + String get tooltipRemove => 'Remover'; + + @override + String get tooltipClear => 'Limpar'; + + @override + String get tooltipPaste => 'Colar'; + + @override + String get filenameFormat => 'Formato do Nome do Arquivo'; + + @override + String filenameFormatPreview(String preview) { + return 'Prévia: $preview'; + } + + @override + String get filenameAvailablePlaceholders => 'Substituições permitidas:'; + + @override + String filenameHint(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get folderOrganization => 'Organização de Pastas'; + + @override + String get folderOrganizationNone => 'Nenhuma organização'; + + @override + String get folderOrganizationByArtist => 'Por Artista'; + + @override + String get folderOrganizationByAlbum => 'Por Album'; + + @override + String get folderOrganizationByArtistAlbum => 'Artista/Álbum'; + + @override + String get folderOrganizationDescription => + 'Organizar arquivos baixados em pastas'; + + @override + String get folderOrganizationNoneSubtitle => + 'Todos os arquivos na pasta de download'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Pasta separada para cada artista'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Pasta separada para cada álbum'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Pastas aninhadas para artista e álbum'; + + @override + String get updateAvailable => 'Atualização Disponível'; + + @override + String updateNewVersion(String version) { + return 'A versão $version está disponível'; + } + + @override + String get updateDownload => 'Baixar'; + + @override + String get updateLater => 'Depois'; + + @override + String get updateChangelog => 'Lista de alterações'; + + @override + String get updateStartingDownload => 'Iniciando download...'; + + @override + String get updateDownloadFailed => 'Download falhou'; + + @override + String get updateFailedMessage => 'Falha ao baixar a atualização'; + + @override + String get updateNewVersionReady => 'Uma nova versão está pronta'; + + @override + String get updateCurrent => 'Atual'; + + @override + String get updateNew => 'Novo'; + + @override + String get updateDownloading => 'Baixando...'; + + @override + String get updateWhatsNew => 'Novidades'; + + @override + String get updateDownloadInstall => 'Baixar e Instalar'; + + @override + String get updateDontRemind => 'Não lembrar'; + + @override + String get providerPriority => 'Prioridade de Provedor'; + + @override + String get providerPrioritySubtitle => + 'Arraste para reordenar os provedores de download'; + + @override + String get providerPriorityTitle => 'Prioridade de Provedor'; + + @override + String get providerPriorityDescription => + 'Arraste para reordenar provedores de download. O aplicativo irá tentar provedores de cima para baixo ao baixar as faixas.'; + + @override + String get providerPriorityInfo => + 'Se uma faixa não estiver disponível no primeiro provedor, o aplicativo irá tentar automaticamente a próxima.'; + + @override + String get providerBuiltIn => 'Embutido'; + + @override + String get providerExtension => 'Extensão'; + + @override + String get metadataProviderPriority => 'Prioridade de Provedor de Metadados'; + + @override + String get metadataProviderPrioritySubtitle => + 'Ordem usada para obter metadados de faixa'; + + @override + String get metadataProviderPriorityTitle => 'Prioridade de Metadados'; + + @override + String get metadataProviderPriorityDescription => + 'Arraste para reordenar provedores de metadados. O aplicativo tentará provedores de cima para baixo ao procurar por faixas e buscar metadados.'; + + @override + String get metadataProviderPriorityInfo => + 'O Deezer não tem limites de taxa e é recomendado como principal. O Spotify pode limitar a taxa após muitas solicitações.'; + + @override + String get metadataNoRateLimits => 'Sem limites de taxa'; + + @override + String get metadataMayRateLimit => 'Pode ter limites de taxa'; + + @override + String get logTitle => 'Registros'; + + @override + String get logCopy => 'Copiar Registros'; + + @override + String get logClear => 'Limpar Registros'; + + @override + String get logShare => 'Compartilhar Registros'; + + @override + String get logEmpty => 'Ainda não há registros'; + + @override + String get logCopied => 'Registros copiados para área de transferência'; + + @override + String get logSearchHint => 'Pesquisar registros...'; + + @override + String get logFilterLevel => 'Nível'; + + @override + String get logFilterSection => 'Filtro'; + + @override + String get logShareLogs => 'Compartilhar registros'; + + @override + String get logClearLogs => 'Limpar registros'; + + @override + String get logClearLogsTitle => 'Limpar Registros'; + + @override + String get logClearLogsMessage => + 'Tem certeza de que deseja limpar todos os registros?'; + + @override + String get logIspBlocking => 'BLOQUEIO DE ISP DETECTADO'; + + @override + String get logRateLimited => 'TAXA LIMITADA (RATELIMITED)'; + + @override + String get logNetworkError => 'ERRO DE REDE'; + + @override + String get logTrackNotFound => 'FAIXA NÃO ENCONTRADA'; + + @override + String get logFilterBySeverity => 'Filtrar registros por gravidade'; + + @override + String get logNoLogsYet => 'Ainda não há registros'; + + @override + String get logNoLogsYetSubtitle => + 'Os registros aparecerão aqui enquanto você usa o aplicativo'; + + @override + String get logIssueSummary => 'Resumo do Problemas'; + + @override + String get logIspBlockingDescription => + 'O seu provedor pode estar bloqueando o acesso aos serviços de download'; + + @override + String get logIspBlockingSuggestion => + 'Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8'; + + @override + String get logRateLimitedDescription => 'Muitas solicitações ao serviço'; + + @override + String get logRateLimitedSuggestion => + 'Aguarde alguns minutos antes de tentar novamente'; + + @override + String get logNetworkErrorDescription => 'Problemas de conexão detectados'; + + @override + String get logNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String logTotalErrors(int count) { + return 'Total errors: $count'; + } + + @override + String logAffected(String domains) { + return 'Affected: $domains'; + } + + @override + String logEntriesFiltered(int count) { + return 'Entries ($count filtered)'; + } + + @override + String logEntries(int count) { + return 'Entries ($count)'; + } + + @override + String get credentialsTitle => 'Spotify Credentials'; + + @override + String get credentialsDescription => + 'Enter your Client ID and Secret to use your own Spotify application quota.'; + + @override + String get credentialsClientId => 'Client ID'; + + @override + String get credentialsClientIdHint => 'Colar Client ID'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Colar Client Secret'; + + @override + String get channelStable => 'Estável'; + + @override + String get channelPreview => 'Prévia'; + + @override + String get sectionSearchSource => 'Origem da Pesquisa'; + + @override + String get sectionDownload => 'Download'; + + @override + String get sectionPerformance => 'Desempenho'; + + @override + String get sectionApp => 'Aplicativo'; + + @override + String get sectionData => 'Dados'; + + @override + String get sectionDebug => 'Depuração'; + + @override + String get sectionService => 'Serviço'; + + @override + String get sectionAudioQuality => 'Qualidade de Áudio'; + + @override + String get sectionFileSettings => 'Configurações de Arquivo'; + + @override + String get sectionColor => 'Cor'; + + @override + String get sectionTheme => 'Tema'; + + @override + String get sectionLayout => 'Layout'; + + @override + String get sectionLanguage => 'Idioma'; + + @override + String get appearanceLanguage => 'Idioma do aplicativo'; + + @override + String get appearanceLanguageSubtitle => 'Escolha o seu idioma preferido'; + + @override + String get settingsAppearanceSubtitle => 'Tema, cores, exibição'; + + @override + String get settingsDownloadSubtitle => + 'Serviço, qualidade, formato de nome de arquivo'; + + @override + String get settingsOptionsSubtitle => + 'Fallback, letras, arte de capa, atualizações'; + + @override + String get settingsExtensionsSubtitle => 'Gerenciar provedores de download'; + + @override + String get settingsLogsSubtitle => 'Ver logs do app para depuração'; + + @override + String get loadingSharedLink => 'Carregando link compartilhado...'; + + @override + String get pressBackAgainToExit => 'Pressione voltar novamente para sair'; + + @override + String get tracksHeader => 'Faixas'; + + @override + String downloadAllCount(int count) { + return 'Baixar Todos ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count faixas', + one: '1 faixa', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copiar caminho do arquivo'; + + @override + String get trackRemoveFromDevice => 'Remover do dispositivo'; + + @override + String get trackLoadLyrics => 'Carregar Letras'; + + @override + String get trackMetadata => 'Metadados'; + + @override + String get trackFileInfo => 'Informações do Arquivo'; + + @override + String get trackLyrics => 'Letras'; + + @override + String get trackFileNotFound => 'Arquivo não encontrado'; + + @override + String get trackOpenInDeezer => 'Abrir no Deezer'; + + @override + String get trackOpenInSpotify => 'Abrir no Spotify'; + + @override + String get trackTrackName => 'Nome da faixa'; + + @override + String get trackArtist => 'Artista'; + + @override + String get trackAlbumArtist => 'Artista do álbum'; + + @override + String get trackAlbum => 'Álbum'; + + @override + String get trackTrackNumber => 'Número da faixa'; + + @override + String get trackDiscNumber => 'Número do disco'; + + @override + String get trackDuration => 'Duração'; + + @override + String get trackAudioQuality => 'Qualidade de Áudio'; + + @override + String get trackReleaseDate => 'Data de lançamento'; + + @override + String get trackDownloaded => 'Baixado'; + + @override + String get trackCopyLyrics => 'Copy lyrics'; + + @override + String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + + @override + String get trackLyricsTimeout => 'Request timed out. Try again later.'; + + @override + String get trackLyricsLoadFailed => 'Failed to load lyrics'; + + @override + String get trackCopiedToClipboard => 'Copied to clipboard'; + + @override + String get trackDeleteConfirmTitle => 'Remove from device?'; + + @override + String get trackDeleteConfirmMessage => + 'This will permanently delete the downloaded file and remove it from your history.'; + + @override + String trackCannotOpen(String message) { + return 'Cannot open: $message'; + } + + @override + String get dateToday => 'Today'; + + @override + String get dateYesterday => 'Yesterday'; + + @override + String dateDaysAgo(int count) { + return '$count days ago'; + } + + @override + String dateWeeksAgo(int count) { + return '$count weeks ago'; + } + + @override + String dateMonthsAgo(int count) { + return '$count months ago'; + } + + @override + String get concurrentSequential => 'Sequential'; + + @override + String get concurrentParallel2 => '2 Parallel'; + + @override + String get concurrentParallel3 => '3 Parallel'; + + @override + String get tapToSeeError => 'Tap to see error details'; + + @override + String get storeFilterAll => 'All'; + + @override + String get storeFilterMetadata => 'Metadata'; + + @override + String get storeFilterDownload => 'Download'; + + @override + String get storeFilterUtility => 'Utility'; + + @override + String get storeFilterLyrics => 'Lyrics'; + + @override + String get storeFilterIntegration => 'Integration'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get storeNoResults => 'No extensions found'; + + @override + String get extensionProviderPriority => 'Provider Priority'; + + @override + String get extensionInstallButton => 'Install Extension'; + + @override + String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Use built-in search'; + + @override + String get extensionAuthor => 'Author'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Capabilities'; + + @override + String get extensionMetadataProvider => 'Metadata Provider'; + + @override + String get extensionDownloadProvider => 'Download Provider'; + + @override + String get extensionLyricsProvider => 'Lyrics Provider'; + + @override + String get extensionUrlHandler => 'URL Handler'; + + @override + String get extensionQualityOptions => 'Quality Options'; + + @override + String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + + @override + String get extensionPermissions => 'Permissions'; + + @override + String get extensionSettings => 'Settings'; + + @override + String get extensionRemoveButton => 'Remover Extensão'; + + @override + String get extensionUpdated => 'Atualizado'; + + @override + String get extensionMinAppVersion => 'Versão Mínima do App'; + + @override + String get extensionCustomTrackMatching => + 'Correspondência de Faixa Personalizada'; + + @override + String get extensionPostProcessing => 'Pós-Processamento'; + + @override + String extensionHooksAvailable(int count) { + return '$count gancho(s) disponíveis'; + } + + @override + String extensionPatternsCount(int count) { + return '$count padrão(ões)'; + } + + @override + String extensionStrategy(String strategy) { + return 'Estratégia: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Prioridade de Provedor'; + + @override + String get extensionsInstalledSection => 'Extensões Instaladas'; + + @override + String get extensionsNoExtensions => 'Nenhuma extensão instalada'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Instale arquivos .spotiflac-ext para adicionar novos provedores'; + + @override + String get extensionsInstallButton => 'Instalar Extensão'; + + @override + String get extensionsInfoTip => + 'Extensões podem adicionar novos metadados e baixar provedores. Somente instale extensões a partir de fontes confiáveis.'; + + @override + String get extensionsInstalledSuccess => 'Extensão instalada com sucesso'; + + @override + String get extensionsDownloadPriority => 'Prioridade de Download'; + + @override + String get extensionsDownloadPrioritySubtitle => + 'Definir ordem do serviço de download'; + + @override + String get extensionsNoDownloadProvider => + 'Nenhuma extensão com provedor de download'; + + @override + String get extensionsMetadataPriority => 'Prioridade de Metadados'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Definir ordem de origem de pesquisa e metadados'; + + @override + String get extensionsNoMetadataProvider => + 'Nenhuma extensão com provedor de metadados'; + + @override + String get extensionsSearchProvider => 'Provedor de Pesquisa'; + + @override + String get extensionsNoCustomSearch => + 'Nenhuma extensão com pesquisa personalizada'; + + @override + String get extensionsSearchProviderDescription => + 'Escolha qual serviço utilizar para pesquisar faixas'; + + @override + String get extensionsCustomSearch => 'Busca personalizada'; + + @override + String get extensionsErrorLoading => 'Erro ao carregar extensão'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24-bit / até 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24-bit / até 192kHz'; + + @override + String get qualityNote => + 'A qualidade real depende da faixa que estiver disponível no serviço'; + + @override + String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar'; + + @override + String get downloadDirectory => 'Pasta de Download'; + + @override + String get downloadSeparateSinglesFolder => 'Pasta de Singles Separada'; + + @override + String get downloadAlbumFolderStructure => 'Estrutura da Pasta de Álbum'; + + @override + String get downloadSaveFormat => 'Formato para Salvar'; + + @override + String get downloadSelectService => 'Selecionar Serviço'; + + @override + String get downloadSelectQuality => 'Selecionar Qualidade'; + + @override + String get downloadFrom => 'Baixar De'; + + @override + String get downloadDefaultQualityLabel => 'Qualidade Padrão'; + + @override + String get downloadBestAvailable => 'Melhor Disponível'; + + @override + String get folderNone => 'Nenhum'; + + @override + String get folderNoneSubtitle => 'Save all files directly to download folder'; + + @override + String get folderArtist => 'Artist'; + + @override + String get folderArtistSubtitle => 'Artist Name/filename'; + + @override + String get folderAlbum => 'Album'; + + @override + String get folderAlbumSubtitle => 'Album Name/filename'; + + @override + String get folderArtistAlbum => 'Artist/Album'; + + @override + String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; + + @override + String get serviceTidal => 'Tidal'; + + @override + String get serviceQobuz => 'Qobuz'; + + @override + String get serviceAmazon => 'Amazon'; + + @override + String get serviceDeezer => 'Deezer'; + + @override + String get serviceSpotify => 'Spotify'; + + @override + String get appearanceAmoledDark => 'AMOLED Dark'; + + @override + String get appearanceAmoledDarkSubtitle => 'Pure black background'; + + @override + String get appearanceChooseAccentColor => 'Choose Accent Color'; + + @override + String get appearanceChooseTheme => 'Theme Mode'; + + @override + String get queueTitle => 'Download Queue'; + + @override + String get queueClearAll => 'Clear All'; + + @override + String get queueClearAllMessage => + 'Are you sure you want to clear all downloads?'; + + @override + String get queueEmpty => 'No downloads in queue'; + + @override + String get queueEmptySubtitle => 'Add tracks from the home screen'; + + @override + String get queueClearCompleted => 'Clear completed'; + + @override + String get queueDownloadFailed => 'Download Failed'; + + @override + String get queueTrackLabel => 'Track:'; + + @override + String get queueArtistLabel => 'Artist:'; + + @override + String get queueErrorLabel => 'Error:'; + + @override + String get queueUnknownError => 'Unknown error'; + + @override + String get albumFolderArtistAlbum => 'Artist / Album'; + + @override + String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + + @override + String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Albums/Artist Name/[2005] Album Name/'; + + @override + String get albumFolderAlbumOnly => 'Album Only'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + + @override + String get albumFolderYearAlbum => '[Year] Album'; + + @override + String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + + @override + String get downloadedAlbumDeleteSelected => 'Delete Selected'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + } + + @override + String get downloadedAlbumTracksHeader => 'Tracks'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count selected'; + } + + @override + String get downloadedAlbumAllSelected => 'All tracks selected'; + + @override + String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0e96f465..8bd4c674 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -13,67 +13,70 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; @override - String get navHome => 'Home'; + String get navHome => 'Главная'; @override - String get navHistory => 'History'; + String get navHistory => 'История'; @override - String get navSettings => 'Settings'; + String get navSettings => 'Настройки'; @override - String get navStore => 'Store'; + String get navStore => 'Магазин'; @override - String get homeTitle => 'Home'; + String get homeTitle => 'Главная'; @override - String get homeSearchHint => 'Paste Spotify URL or search...'; + String get homeSearchHint => 'Вставьте URL Spotify или выполните поиск...'; @override String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; + return 'Искать с помощью $extensionName...'; } @override - String get homeSubtitle => 'Paste a Spotify link or search by name'; + String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию'; @override - String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + String get homeSupports => + 'Поддерживается: Трек, Альбом, Плейлист, URL исполнителя'; @override - String get homeRecent => 'Recent'; + String get homeRecent => 'Недавние'; @override - String get historyTitle => 'History'; + String get historyTitle => 'История'; @override String historyDownloading(int count) { - return 'Downloading ($count)'; + return 'Скачивание ($count)'; } @override - String get historyDownloaded => 'Downloaded'; + String get historyDownloaded => 'Скачано'; @override - String get historyFilterAll => 'All'; + String get historyFilterAll => 'Все'; @override - String get historyFilterAlbums => 'Albums'; + String get historyFilterAlbums => 'Альбомы'; @override - String get historyFilterSingles => 'Singles'; + String get historyFilterSingles => 'Синглы'; @override String historyTracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count треков', + many: '$count треков', + few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @@ -83,247 +86,254 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count albums', - one: '1 album', + other: '$count альбомов', + many: '$count альбомов', + few: '$count альбома', + one: '$count альбом', ); return '$_temp0'; } @override - String get historyNoDownloads => 'No download history'; + String get historyNoDownloads => 'Нет истории скачиваний'; @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; + String get historyNoDownloadsSubtitle => 'Скачанные треки появятся здесь'; @override - String get historyNoAlbums => 'No album downloads'; + String get historyNoAlbums => 'Нет скачанных альбомов'; @override String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; + 'Скачайте несколько треков из альбома, чтобы увидеть их здесь'; @override - String get historyNoSingles => 'No single downloads'; + String get historyNoSingles => 'Нет скачанных синглов'; @override String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; + 'Здесь будут отображаться загрузки синглов'; @override - String get settingsTitle => 'Settings'; + String get settingsTitle => 'Настройки'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => 'Скачивание'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => 'Внешний вид'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => 'Опции'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => 'Расширения'; @override - String get settingsAbout => 'About'; + String get settingsAbout => 'О программе'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => 'Скачивание'; @override - String get downloadLocation => 'Download Location'; + String get downloadLocation => 'Папка для скачивания'; @override - String get downloadLocationSubtitle => 'Choose where to save files'; + String get downloadLocationSubtitle => 'Выберите, куда сохранить файлы'; @override - String get downloadLocationDefault => 'Default location'; + String get downloadLocationDefault => 'Расположение по умолчанию'; @override - String get downloadDefaultService => 'Default Service'; + String get downloadDefaultService => 'Сервис по умолчанию'; @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + String get downloadDefaultServiceSubtitle => + 'Сервис, используемый для скачивания'; @override - String get downloadDefaultQuality => 'Default Quality'; + String get downloadDefaultQuality => 'Качество по умолчанию'; @override - String get downloadAskQuality => 'Ask Quality Before Download'; + String get downloadAskQuality => 'Спрашивать качество перед скачиванием'; @override String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; + 'Показывать выбор качества для каждого скачивания'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => 'Формат имени файла'; @override - String get downloadFolderOrganization => 'Folder Organization'; + String get downloadFolderOrganization => 'Организация папок'; @override - String get downloadSeparateSingles => 'Separate Singles'; + String get downloadSeparateSingles => 'Разделять синглы'; @override String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; + 'Помещать синглы в отдельную папку'; @override - String get qualityBest => 'Best Available'; + String get qualityBest => 'Лучшее из доступных'; @override String get qualityFlac => 'FLAC'; @override - String get quality320 => '320 kbps'; + String get quality320 => '320 кбит/с'; @override - String get quality128 => '128 kbps'; + String get quality128 => '128 кбит/с'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => 'Внешний вид'; @override - String get appearanceTheme => 'Theme'; + String get appearanceTheme => 'Тема'; @override - String get appearanceThemeSystem => 'System'; + String get appearanceThemeSystem => 'Системная'; @override - String get appearanceThemeLight => 'Light'; + String get appearanceThemeLight => 'Светлая'; @override - String get appearanceThemeDark => 'Dark'; + String get appearanceThemeDark => 'Тёмная'; @override - String get appearanceDynamicColor => 'Dynamic Color'; + String get appearanceDynamicColor => 'Динамический цвет'; @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + String get appearanceDynamicColorSubtitle => + 'Использовать цвета из ваших обоев'; @override - String get appearanceAccentColor => 'Accent Color'; + String get appearanceAccentColor => 'Акцентный цвет'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => 'Отображение истории'; @override - String get appearanceHistoryViewList => 'List'; + String get appearanceHistoryViewList => 'Список'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'Сетка'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => 'Опции'; @override - String get optionsSearchSource => 'Search Source'; + String get optionsSearchSource => 'Поиск источника'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => 'Основной провайдер'; @override String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; + 'Сервис, используемый при поиске по названию трека.'; @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return 'Используется расширение: $extensionName'; } @override String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + 'Нажмите Deezer или Spotify для возврата с расширения'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => 'Автоматический переход'; @override String get optionsAutoFallbackSubtitle => - 'Try other services if download fails'; + 'Попробовать другие сервисы при сбое загрузки'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => + 'Использовать провайдера расширений'; @override - String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + String get optionsUseExtensionProvidersOn => + 'Сначала будут опробованы расширения'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => + 'Использование только встроенных провайдеров'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => 'Вставить текст песни'; @override String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; + 'Вставить синхронизированные тексты в FLAC файлы'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => 'Максимальное качество обложки'; @override String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; + 'Скачивать обложку в макс. разрешении'; @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; + String get optionsConcurrentDownloads => 'Одновременные загрузки'; @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + String get optionsConcurrentSequential => 'Последовательно (1 за раз)'; @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count параллельных загрузок'; } @override String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; + 'Параллельные загрузки могут вызвать ограничение скорости'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => 'Магазин расширений'; @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + String get optionsExtensionStoreSubtitle => + 'Показывать вкладку Магазин в гл. меню'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => 'Проверить обновления'; @override - String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; + String get optionsCheckUpdatesSubtitle => 'Уведомлять о наличии новой версии'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => 'Канал обновлений'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => 'Только стабильные релизы'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => 'Предварительные версии'; @override String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; + 'Предварительная версия может содержать ошибки или неполные функции'; @override - String get optionsClearHistory => 'Clear Download History'; + String get optionsClearHistory => 'Очистить историю загрузок'; @override String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; + 'Удалить все скачанные треки из истории'; @override - String get optionsDetailedLogging => 'Detailed Logging'; + String get optionsDetailedLogging => 'Подробный лог'; @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + String get optionsDetailedLoggingOn => 'Ведутся подробные логи'; @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; + String get optionsDetailedLoggingOff => 'Включить для отчётов об ошибках'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Учётные данные Spotify'; @override String optionsSpotifyCredentialsConfigured(String clientId) { @@ -331,804 +341,829 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + String get optionsSpotifyCredentialsRequired => + 'Необходимо - нажмите для настройки'; @override String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + 'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => 'Расширения'; @override - String get extensionsInstalled => 'Installed Extensions'; + String get extensionsInstalled => 'Установленные расширения'; @override - String get extensionsNone => 'No extensions installed'; + String get extensionsNone => 'Нет установленных расширений'; @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + String get extensionsNoneSubtitle => + 'Установка расширений из вкладки Магазин'; @override - String get extensionsEnabled => 'Enabled'; + String get extensionsEnabled => 'Включено'; @override - String get extensionsDisabled => 'Disabled'; + String get extensionsDisabled => 'Выключено'; @override String extensionsVersion(String version) { - return 'Version $version'; + return 'Версия $version'; } @override String extensionsAuthor(String author) { - return 'by $author'; + return 'от $author'; } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => 'Удалить'; @override - String get extensionsSetAsSearch => 'Set as Search Provider'; + String get extensionsSetAsSearch => 'Установить в качестве поисковой системы'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => 'Магазин расширений'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => 'Поиск расширений...'; @override - String get storeInstall => 'Install'; + String get storeInstall => 'Установить'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => 'Установлено'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => 'Обновить'; @override - String get aboutTitle => 'About'; + String get aboutTitle => 'О программе'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => 'Участники'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => 'Разработчик мобильной версии'; @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + String get aboutOriginalCreator => 'Создатель оригинального SpotiFLAC'; @override String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; + 'Талантливый художник, который создал наш красивый логотип приложения!'; @override - String get aboutSpecialThanks => 'Special Thanks'; + String get aboutTranslators => 'Translators'; @override - String get aboutLinks => 'Links'; + String get aboutSpecialThanks => 'Особая благодарность'; @override - String get aboutMobileSource => 'Mobile source code'; + String get aboutLinks => 'Ссылки'; @override - String get aboutPCSource => 'PC source code'; + String get aboutMobileSource => 'Исходный код мобильной версии'; @override - String get aboutReportIssue => 'Report an issue'; + String get aboutPCSource => 'Исходный код ПК версии'; @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + String get aboutReportIssue => 'Сообщить о проблеме'; @override - String get aboutFeatureRequest => 'Feature request'; + String get aboutReportIssueSubtitle => 'Сообщите о возникших проблемах'; @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + String get aboutFeatureRequest => 'Предложить новую функцию'; @override - String get aboutSupport => 'Support'; + String get aboutFeatureRequestSubtitle => + 'Предложить новые функции для приложения'; @override - String get aboutBuyMeCoffee => 'Buy me a coffee'; + String get aboutSupport => 'Поддержка'; @override - String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + String get aboutBuyMeCoffee => 'Купить мне кофе'; @override - String get aboutApp => 'App'; + String get aboutBuyMeCoffeeSubtitle => 'Поддержать разработку на Ko-fi'; @override - String get aboutVersion => 'Version'; + String get aboutApp => 'Приложение'; + + @override + String get aboutVersion => 'Версия'; @override String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + 'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!'; @override String get aboutSachinsenalDesc => - 'The original HiFi project creator. The foundation of Tidal integration!'; + 'Оригинальный создатель проекта HiFi. Основатель Tidal интеграции!'; @override String get aboutDoubleDouble => 'DoubleDouble'; @override String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; + 'Удивительный API для загрузок Amazon Music. Спасибо за то, что сделали это бесплатно!'; @override String get aboutDabMusic => 'DAB Music'; @override String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + 'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!'; @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; @override - String get albumTitle => 'Album'; + String get albumTitle => 'Альбом'; @override String albumTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count треков', + many: '$count треков', + few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @override - String get albumDownloadAll => 'Download All'; + String get albumDownloadAll => 'Скачать всё'; @override - String get albumDownloadRemaining => 'Download Remaining'; + String get albumDownloadRemaining => 'Скачать оставшиеся'; @override - String get playlistTitle => 'Playlist'; + String get playlistTitle => 'Плейлист'; @override - String get artistTitle => 'Artist'; + String get artistTitle => 'Исполнитель'; @override - String get artistAlbums => 'Albums'; + String get artistAlbums => 'Альбомы'; @override - String get artistSingles => 'Singles & EPs'; + String get artistSingles => 'Синглы и EP'; @override - String get artistCompilations => 'Compilations'; + String get artistCompilations => 'Сборники'; @override String artistReleases(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count releases', - one: '1 release', + other: '$count релизов', + many: '$count релизов', + few: '$count релиза', + one: '$count релиз', ); return '$_temp0'; } @override - String get artistPopular => 'Popular'; + String get artistPopular => 'Популярное'; @override String artistMonthlyListeners(String count) { - return '$count monthly listeners'; + return '$count слушателей в месяц'; } @override - String get trackMetadataTitle => 'Track Info'; + String get trackMetadataTitle => 'Информация о треке'; @override - String get trackMetadataArtist => 'Artist'; + String get trackMetadataArtist => 'Исполнитель'; @override - String get trackMetadataAlbum => 'Album'; + String get trackMetadataAlbum => 'Альбом'; @override - String get trackMetadataDuration => 'Duration'; + String get trackMetadataDuration => 'Продолжительность'; @override - String get trackMetadataQuality => 'Quality'; + String get trackMetadataQuality => 'Качество'; @override - String get trackMetadataPath => 'File Path'; + String get trackMetadataPath => 'Путь к файлу'; @override - String get trackMetadataDownloadedAt => 'Downloaded'; + String get trackMetadataDownloadedAt => 'Скачано'; @override - String get trackMetadataService => 'Service'; + String get trackMetadataService => 'Сервис'; @override - String get trackMetadataPlay => 'Play'; + String get trackMetadataPlay => 'Воспроизвести'; @override - String get trackMetadataShare => 'Share'; + String get trackMetadataShare => 'Поделиться'; @override - String get trackMetadataDelete => 'Delete'; + String get trackMetadataDelete => 'Удалить'; @override - String get trackMetadataRedownload => 'Re-download'; + String get trackMetadataRedownload => 'Скачать снова'; @override - String get trackMetadataOpenFolder => 'Open Folder'; + String get trackMetadataOpenFolder => 'Открыть папку'; @override - String get setupTitle => 'Welcome to SpotiFLAC'; + String get setupTitle => 'Добро пожаловать в SpotiFLAC'; @override - String get setupSubtitle => 'Let\'s get you started'; + String get setupSubtitle => 'Давайте начнем'; @override - String get setupStoragePermission => 'Storage Permission'; + String get setupStoragePermission => 'Доступ к хранилищу'; @override String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; + 'Необходимо для сохранения загруженных файлов'; @override - String get setupStoragePermissionGranted => 'Permission granted'; + String get setupStoragePermissionGranted => 'Разрешение предоставлено'; @override - String get setupStoragePermissionDenied => 'Permission denied'; + String get setupStoragePermissionDenied => 'Разрешение не предоставлено'; @override - String get setupGrantPermission => 'Grant Permission'; + String get setupGrantPermission => 'Предоставить разрешение'; @override - String get setupDownloadLocation => 'Download Location'; + String get setupDownloadLocation => 'Папка для скачивания'; @override - String get setupChooseFolder => 'Choose Folder'; + String get setupChooseFolder => 'Выбрать папку'; @override - String get setupContinue => 'Continue'; + String get setupContinue => 'Продолжить'; @override - String get setupSkip => 'Skip for now'; + String get setupSkip => 'Пропустить'; @override - String get setupStorageAccessRequired => 'Storage Access Required'; + String get setupStorageAccessRequired => 'Требуется доступ к хранилищу'; @override String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; + 'SpotiFLAC требуется разрешение \"Доступ ко всем файлам\" для сохранения музыкальных файлов в выбранную папку.'; @override String get setupStorageAccessMessageAndroid11 => - 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + 'Для Android 11+ требуется разрешение \"Доступ ко всем файлам\" для сохранения файлов в выбранную вами папку загрузки.'; @override - String get setupOpenSettings => 'Open Settings'; + String get setupOpenSettings => 'Открыть настройки'; @override String get setupPermissionDeniedMessage => - 'Permission denied. Please grant all permissions to continue.'; + 'В разрешении отказано. Пожалуйста, предоставьте все разрешения для продолжения.'; @override String setupPermissionRequired(String permissionType) { - return '$permissionType Permission Required'; + return 'Требуется разрешение $permissionType'; } @override String setupPermissionRequiredMessage(String permissionType) { - return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + return 'Для оптимальной работы требуется разрешение $permissionType. Вы можете изменить это позже в настройках.'; } @override - String get setupSelectDownloadFolder => 'Select Download Folder'; + String get setupSelectDownloadFolder => 'Выбрать папку для скачивания'; @override - String get setupUseDefaultFolder => 'Use Default Folder?'; + String get setupUseDefaultFolder => 'Использовать папку по умолчанию?'; @override String get setupNoFolderSelected => - 'No folder selected. Would you like to use the default Music folder?'; + 'Папка не выбрана. Хотите использовать папку Музыка по умолчанию?'; @override - String get setupUseDefault => 'Use Default'; + String get setupUseDefault => 'По умолчанию'; @override - String get setupDownloadLocationTitle => 'Download Location'; + String get setupDownloadLocationTitle => 'Папка для скачивания'; @override String get setupDownloadLocationIosMessage => - 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + 'В iOS загрузки сохраняются в папке Документы приложения. Вы можете получить к ним доступ через приложение Файлы.'; @override - String get setupAppDocumentsFolder => 'App Documents Folder'; + String get setupAppDocumentsFolder => 'Папка Документы приложения'; @override String get setupAppDocumentsFolderSubtitle => - 'Recommended - accessible via Files app'; + 'Рекомендуется - доступ через Файлы'; @override - String get setupChooseFromFiles => 'Choose from Files'; + String get setupChooseFromFiles => 'Выбрать из файлов'; @override - String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + String get setupChooseFromFilesSubtitle => + 'Выберите iCloud или другое местоположение'; @override String get setupIosEmptyFolderWarning => - 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + 'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.'; @override - String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC'; @override - String get setupStepStorage => 'Storage'; + String get setupStepStorage => 'Хранилище'; @override - String get setupStepNotification => 'Notification'; + String get setupStepNotification => 'Уведомления'; @override - String get setupStepFolder => 'Folder'; + String get setupStepFolder => 'Папка'; @override String get setupStepSpotify => 'Spotify'; @override - String get setupStepPermission => 'Permission'; + String get setupStepPermission => 'Разрешение'; @override - String get setupStorageGranted => 'Storage Permission Granted!'; + String get setupStorageGranted => 'Доступ к хранилищу предоставлен!'; @override - String get setupStorageRequired => 'Storage Permission Required'; + String get setupStorageRequired => 'Требуется доступ к хранилищу'; @override String get setupStorageDescription => - 'SpotiFLAC needs storage permission to save your downloaded music files.'; + 'SpotiFLAC требуется разрешение на хранение для сохранения скачанных файлов.'; @override - String get setupNotificationGranted => 'Notification Permission Granted!'; + String get setupNotificationGranted => + 'Разрешение на уведомление предоставлено!'; @override - String get setupNotificationEnable => 'Enable Notifications'; + String get setupNotificationEnable => 'Включить уведомления'; @override String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; + 'Получайте уведомления о завершении загрузки или о необходимости привлечения внимания.'; @override - String get setupFolderSelected => 'Download Folder Selected!'; + String get setupFolderSelected => 'Папка для загрузки выбрана!'; @override - String get setupFolderChoose => 'Choose Download Folder'; + String get setupFolderChoose => 'Выбрать папку для скачивания'; @override String get setupFolderDescription => - 'Select a folder where your downloaded music will be saved.'; + 'Выберите папку, в которой будет сохраняться скачанная музыка.'; @override - String get setupChangeFolder => 'Change Folder'; + String get setupChangeFolder => 'Сменить папку'; @override - String get setupSelectFolder => 'Select Folder'; + String get setupSelectFolder => 'Выбрать папку'; @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; + String get setupSpotifyApiOptional => 'Spotify API (необязательно)'; @override String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; + 'Добавьте свои учётные данные Spotify для улучшения результатов поиска и доступа к эксклюзивному контенту Spotify.'; @override - String get setupUseSpotifyApi => 'Use Spotify API'; + String get setupUseSpotifyApi => 'Использовать Spotify API'; @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; + String get setupEnterCredentialsBelow => 'Введите ваши учётные данные ниже'; @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; + String get setupUsingDeezer => 'Использование Deezer (аккаунт не требуется)'; @override - String get setupEnterClientId => 'Enter Spotify Client ID'; + String get setupEnterClientId => 'Введите Client ID Spotify'; @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; + String get setupEnterClientSecret => 'Введите Spotify Client Secret'; @override String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; + 'Получите бесплатный API учётной записи на панели разработчика Spotify.'; @override - String get setupEnableNotifications => 'Enable Notifications'; + String get setupEnableNotifications => 'Включить уведомления'; @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; + String get setupProceedToNextStep => + 'Теперь вы можете перейти к следующему шагу.'; @override String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; + 'Вы будете получать уведомления о ходе загрузки.'; @override String get setupNotificationBackgroundDescription => - 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + 'Получайте уведомления о ходе и завершении загрузки. Это поможет вам отслеживать загрузки, когда приложение находится в фоновом режиме.'; @override - String get setupSkipForNow => 'Skip for now'; + String get setupSkipForNow => 'Пропустить'; @override - String get setupBack => 'Back'; + String get setupBack => 'Назад'; @override - String get setupNext => 'Next'; + String get setupNext => 'Далее'; @override - String get setupGetStarted => 'Get Started'; + String get setupGetStarted => 'Приступить к работе'; @override - String get setupSkipAndStart => 'Skip & Start'; + String get setupSkipAndStart => 'Пропустить и начать'; @override String get setupAllowAccessToManageFiles => - 'Please enable \"Allow access to manage all files\" in the next screen.'; + 'Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.'; @override String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; + 'Получить учётные данные с developer.spotify.com'; @override - String get dialogCancel => 'Cancel'; + String get dialogCancel => 'Отмена'; @override - String get dialogOk => 'OK'; + String get dialogOk => 'ОК'; @override - String get dialogSave => 'Save'; + String get dialogSave => 'Сохранить'; @override - String get dialogDelete => 'Delete'; + String get dialogDelete => 'Удалить'; @override - String get dialogRetry => 'Retry'; + String get dialogRetry => 'Повторить'; @override - String get dialogClose => 'Close'; + String get dialogClose => 'Закрыть'; @override - String get dialogYes => 'Yes'; + String get dialogYes => 'Да'; @override - String get dialogNo => 'No'; + String get dialogNo => 'Нет'; @override - String get dialogClear => 'Clear'; + String get dialogClear => 'Очистить'; @override - String get dialogConfirm => 'Confirm'; + String get dialogConfirm => 'Подтвердить'; @override - String get dialogDone => 'Done'; + String get dialogDone => 'Готово'; @override - String get dialogImport => 'Import'; + String get dialogImport => 'Импорт'; @override - String get dialogDiscard => 'Discard'; + String get dialogDiscard => 'Отменить'; @override - String get dialogRemove => 'Remove'; + String get dialogRemove => 'Убрать'; @override - String get dialogUninstall => 'Uninstall'; + String get dialogUninstall => 'Удалить'; @override - String get dialogDiscardChanges => 'Discard Changes?'; + String get dialogDiscardChanges => 'Отменить изменения?'; @override String get dialogUnsavedChanges => - 'You have unsaved changes. Do you want to discard them?'; + 'Есть несохраненные изменения. Отменить их?'; @override - String get dialogDownloadFailed => 'Download Failed'; + String get dialogDownloadFailed => 'Ошибка скачивания'; @override - String get dialogTrackLabel => 'Track:'; + String get dialogTrackLabel => 'Трек:'; @override - String get dialogArtistLabel => 'Artist:'; + String get dialogArtistLabel => 'Исполнитель:'; @override - String get dialogErrorLabel => 'Error:'; + String get dialogErrorLabel => 'Ошибка:'; @override - String get dialogClearAll => 'Clear All'; + String get dialogClearAll => 'Очистить всё'; @override String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; + 'Вы уверены, что хотите очистить все загрузки?'; @override - String get dialogRemoveFromDevice => 'Remove from device?'; + String get dialogRemoveFromDevice => 'Удалить с устройства?'; @override - String get dialogRemoveExtension => 'Remove Extension'; + String get dialogRemoveExtension => 'Удалить расширение'; @override String get dialogRemoveExtensionMessage => - 'Are you sure you want to remove this extension? This cannot be undone.'; + 'Вы уверены, что хотите удалить это расширение? Это действие не может быть отменено.'; @override - String get dialogUninstallExtension => 'Uninstall Extension?'; + String get dialogUninstallExtension => 'Удалить расширение?'; @override String dialogUninstallExtensionMessage(String extensionName) { - return 'Are you sure you want to remove $extensionName?'; + return 'Вы уверены, что хотите удалить $extensionName?'; } @override - String get dialogClearHistoryTitle => 'Clear History'; + String get dialogClearHistoryTitle => 'Очистить историю'; @override String get dialogClearHistoryMessage => - 'Are you sure you want to clear all download history? This cannot be undone.'; + 'Вы уверены, что хотите удалить всю историю загрузок? Это действие необратимо.'; @override - String get dialogDeleteSelectedTitle => 'Delete Selected'; + String get dialogDeleteSelectedTitle => 'Удалить выбранные'; @override String dialogDeleteSelectedMessage(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.'; } @override - String get dialogImportPlaylistTitle => 'Import Playlist'; + String get dialogImportPlaylistTitle => 'Импорт плейлиста'; @override String dialogImportPlaylistMessage(int count) { - return 'Found $count tracks in CSV. Add them to download queue?'; + return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?'; + } + + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; } @override String snackbarAddedToQueue(String trackName) { - return 'Added \"$trackName\" to queue'; + return '\"$trackName\" добавлен в очередь'; } @override String snackbarAddedTracksToQueue(int count) { - return 'Added $count tracks to queue'; + return 'Добавлено $count треков в очередь'; } @override String snackbarAlreadyDownloaded(String trackName) { - return '\"$trackName\" already downloaded'; + return '\"$trackName\" уже скачан'; } @override - String get snackbarHistoryCleared => 'History cleared'; + String get snackbarHistoryCleared => 'История очищена'; @override - String get snackbarCredentialsSaved => 'Credentials saved'; + String get snackbarCredentialsSaved => 'Учётные данные сохранены'; @override - String get snackbarCredentialsCleared => 'Credentials cleared'; + String get snackbarCredentialsCleared => 'Учётные данные очищены'; @override String snackbarDeletedTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Deleted $count $_temp0'; + return 'Удалено $count $_temp0'; } @override String snackbarCannotOpenFile(String error) { - return 'Cannot open file: $error'; + return 'Невозможно открыть файл: $error'; } @override - String get snackbarFillAllFields => 'Please fill all fields'; + String get snackbarFillAllFields => 'Пожалуйста, заполните все поля'; @override - String get snackbarViewQueue => 'View Queue'; + String get snackbarViewQueue => 'Просмотр очереди'; @override String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; + return 'Ошибка загрузки: $error'; } @override String snackbarUrlCopied(String platform) { - return '$platform URL copied to clipboard'; + return '$platform ссылка скопирована в буфер обмена'; } @override - String get snackbarFileNotFound => 'File not found'; + String get snackbarFileNotFound => 'Файл не найден'; @override - String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + String get snackbarSelectExtFile => + 'Пожалуйста, выберите .spotiflac-ext-файл'; @override - String get snackbarProviderPrioritySaved => 'Provider priority saved'; + String get snackbarProviderPrioritySaved => 'Приоритет провайдера сохранён'; @override String get snackbarMetadataProviderSaved => - 'Metadata provider priority saved'; + 'Приоритет провайдера метаданных сохранён'; @override String snackbarExtensionInstalled(String extensionName) { - return '$extensionName installed.'; + return '$extensionName установлено.'; } @override String snackbarExtensionUpdated(String extensionName) { - return '$extensionName updated.'; + return '$extensionName Обновлено.'; } @override - String get snackbarFailedToInstall => 'Failed to install extension'; + String get snackbarFailedToInstall => 'Не удалось установить расширение'; @override - String get snackbarFailedToUpdate => 'Failed to update extension'; + String get snackbarFailedToUpdate => 'Не удалось обновить расширение'; @override - String get errorRateLimited => 'Rate Limited'; + String get errorRateLimited => 'Слишком много запросов'; @override String get errorRateLimitedMessage => - 'Too many requests. Please wait a moment before searching again.'; + 'Слишком много запросов. Пожалуйста, подождите минуту перед повторным поиском.'; @override String errorFailedToLoad(String item) { - return 'Failed to load $item'; + return 'Ошибка загрузки $item'; } @override - String get errorNoTracksFound => 'No tracks found'; + String get errorNoTracksFound => 'Треки не найдены'; @override String errorMissingExtensionSource(String item) { - return 'Cannot load $item: missing extension source'; + return 'Невозможно загрузить $item: отсутствует источник расширения'; } @override - String get statusQueued => 'Queued'; + String get statusQueued => 'В очереди'; @override - String get statusDownloading => 'Downloading'; + String get statusDownloading => 'Скачивание'; @override - String get statusFinalizing => 'Finalizing'; + String get statusFinalizing => 'Завершение'; @override - String get statusCompleted => 'Completed'; + String get statusCompleted => 'Завершено'; @override - String get statusFailed => 'Failed'; + String get statusFailed => 'Неудачно'; @override - String get statusSkipped => 'Skipped'; + String get statusSkipped => 'Пропущено'; @override - String get statusPaused => 'Paused'; + String get statusPaused => 'Приостановлено'; @override - String get actionPause => 'Pause'; + String get actionPause => 'Пауза'; @override - String get actionResume => 'Resume'; + String get actionResume => 'Возобновить'; @override - String get actionCancel => 'Cancel'; + String get actionCancel => 'Отмена'; @override - String get actionStop => 'Stop'; + String get actionStop => 'Стоп'; @override - String get actionSelect => 'Select'; + String get actionSelect => 'Выбрать'; @override - String get actionSelectAll => 'Select All'; + String get actionSelectAll => 'Выбрать все'; @override - String get actionDeselect => 'Deselect'; + String get actionDeselect => 'Снять выделение'; @override - String get actionPaste => 'Paste'; + String get actionPaste => 'Вставить'; @override - String get actionImportCsv => 'Import CSV'; + String get actionImportCsv => 'Импорт CSV'; @override - String get actionRemoveCredentials => 'Remove Credentials'; + String get actionRemoveCredentials => 'Удалить учётные данные'; @override - String get actionSaveCredentials => 'Save Credentials'; + String get actionSaveCredentials => 'Сохранить учётные данные'; @override String selectionSelected(int count) { - return '$count selected'; + return '$count выбрано'; } @override - String get selectionAllSelected => 'All tracks selected'; + String get selectionAllSelected => 'Все треки выбраны'; @override - String get selectionTapToSelect => 'Tap tracks to select'; + String get selectionTapToSelect => 'Нажмите на треки для выбора'; @override String selectionDeleteTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Delete $count $_temp0'; + return 'Удалить $count $_temp0'; } @override - String get selectionSelectToDelete => 'Select tracks to delete'; + String get selectionSelectToDelete => 'Выберите треки для удаления'; @override String progressFetchingMetadata(int current, int total) { - return 'Fetching metadata... $current/$total'; + return 'Получение метаданных... $current/$total'; } @override - String get progressReadingCsv => 'Reading CSV...'; + String get progressReadingCsv => 'Чтение CSV...'; @override - String get searchSongs => 'Songs'; + String get searchSongs => 'Песни'; @override - String get searchArtists => 'Artists'; + String get searchArtists => 'Исполнители'; @override - String get searchAlbums => 'Albums'; + String get searchAlbums => 'Альбомы'; @override - String get searchPlaylists => 'Playlists'; + String get searchPlaylists => 'Плейлисты'; @override - String get tooltipPlay => 'Play'; + String get tooltipPlay => 'Воспроизвести'; @override - String get tooltipCancel => 'Cancel'; + String get tooltipCancel => 'Отмена'; @override - String get tooltipStop => 'Stop'; + String get tooltipStop => 'Стоп'; @override - String get tooltipRetry => 'Retry'; + String get tooltipRetry => 'Повторить'; @override - String get tooltipRemove => 'Remove'; + String get tooltipRemove => 'Убрать'; @override - String get tooltipClear => 'Clear'; + String get tooltipClear => 'Очистить'; @override - String get tooltipPaste => 'Paste'; + String get tooltipPaste => 'Вставить'; @override - String get filenameFormat => 'Filename Format'; + String get filenameFormat => 'Формат имени файла'; @override String filenameFormatPreview(String preview) { - return 'Preview: $preview'; + return 'Предпросмотр: $preview'; } @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; + String get filenameAvailablePlaceholders => 'Доступные заполнители:'; @override String filenameHint(Object artist, Object title) { @@ -1136,342 +1171,374 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get folderOrganization => 'Folder Organization'; + String get folderOrganization => 'Организация папок'; @override - String get folderOrganizationNone => 'No organization'; + String get folderOrganizationNone => 'Без организации'; @override - String get folderOrganizationByArtist => 'By Artist'; + String get folderOrganizationByArtist => 'По исполнителю'; @override - String get folderOrganizationByAlbum => 'By Album'; + String get folderOrganizationByAlbum => 'По альбому'; @override - String get folderOrganizationByArtistAlbum => 'Artist/Album'; + String get folderOrganizationByArtistAlbum => 'Исполнитель/Альбом'; @override String get folderOrganizationDescription => - 'Organize downloaded files into folders'; + 'Сортировать скачанные файлы по папкам'; @override - String get folderOrganizationNoneSubtitle => 'All files in download folder'; + String get folderOrganizationNoneSubtitle => 'Все файлы в папке загрузок'; @override String get folderOrganizationByArtistSubtitle => - 'Separate folder for each artist'; + 'Отдельная папка для каждого исполнителя'; @override String get folderOrganizationByAlbumSubtitle => - 'Separate folder for each album'; + 'Отдельная папка для каждого альбома'; @override String get folderOrganizationByArtistAlbumSubtitle => - 'Nested folders for artist and album'; + 'Вложенные папки для исполнителей и альбомов'; @override - String get updateAvailable => 'Update Available'; + String get updateAvailable => 'Доступно обновление'; @override String updateNewVersion(String version) { - return 'Version $version is available'; + return 'Версия $version доступна'; } @override - String get updateDownload => 'Download'; + String get updateDownload => 'Скачать'; @override - String get updateLater => 'Later'; + String get updateLater => 'Позже'; @override - String get updateChangelog => 'Changelog'; + String get updateChangelog => 'Список изменений'; @override - String get updateStartingDownload => 'Starting download...'; + String get updateStartingDownload => 'Загрузка началась...'; @override - String get updateDownloadFailed => 'Download failed'; + String get updateDownloadFailed => 'Не удалось скачать'; @override - String get updateFailedMessage => 'Failed to download update'; + String get updateFailedMessage => 'Сбой загрузки обновления'; @override - String get updateNewVersionReady => 'A new version is ready'; + String get updateNewVersionReady => 'Доступна новая версия'; @override - String get updateCurrent => 'Current'; + String get updateCurrent => 'Текущая'; @override - String get updateNew => 'New'; + String get updateNew => 'Новая'; @override - String get updateDownloading => 'Downloading...'; + String get updateDownloading => 'Скачивание...'; @override - String get updateWhatsNew => 'What\'s New'; + String get updateWhatsNew => 'Что нового'; @override - String get updateDownloadInstall => 'Download & Install'; + String get updateDownloadInstall => 'Скачать и установить'; @override - String get updateDontRemind => 'Don\'t remind'; + String get updateDontRemind => 'Не напоминать'; @override - String get providerPriority => 'Provider Priority'; + String get providerPriority => 'Приоритет провайдера'; @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; + String get providerPrioritySubtitle => 'Перетащите для изменения порядка'; @override - String get providerPriorityTitle => 'Provider Priority'; + String get providerPriorityTitle => 'Приоритет провайдера'; @override String get providerPriorityDescription => - 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; + 'Перетаскивайте, чтобы изменить порядок провайдеров загрузки. Приложение будет пробовать провайдеров сверху вниз при загрузке треков.'; @override String get providerPriorityInfo => - 'If a track is not available on the first provider, the app will automatically try the next one.'; + 'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.'; @override - String get providerBuiltIn => 'Built-in'; + String get providerBuiltIn => 'Встроенные'; @override - String get providerExtension => 'Extension'; + String get providerExtension => 'Расширение'; @override - String get metadataProviderPriority => 'Metadata Provider Priority'; + String get metadataProviderPriority => 'Приоритет провайдера метаданных'; @override String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; + 'Порядок, используемый при получении метаданных'; @override - String get metadataProviderPriorityTitle => 'Metadata Priority'; + String get metadataProviderPriorityTitle => 'Приоритет метаданных'; @override String get metadataProviderPriorityDescription => - 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; + 'Перетаскивайте, чтобы изменить порядок провайдеров метаданных. Приложение будет пробовать провайдеров сверху вниз при поиске треков и извлечении метаданных.'; @override String get metadataProviderPriorityInfo => - 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; + 'Deezer не имеет ограничений по скорости и рекомендуется в качестве основного. Spotify может ограничивать скорость после большого количества запросов.'; @override - String get metadataNoRateLimits => 'No rate limits'; + String get metadataNoRateLimits => 'Без ограничений по скорости'; @override - String get metadataMayRateLimit => 'May rate limit'; + String get metadataMayRateLimit => 'Есть ограничения по скорости'; @override - String get logTitle => 'Logs'; + String get logTitle => 'Логи'; @override - String get logCopy => 'Copy Logs'; + String get logCopy => 'Скопировать логи'; @override - String get logClear => 'Clear Logs'; + String get logClear => 'Очистить логи'; @override - String get logShare => 'Share Logs'; + String get logShare => 'Поделиться логами'; @override - String get logEmpty => 'No logs yet'; + String get logEmpty => 'Логов нет'; @override - String get logCopied => 'Logs copied to clipboard'; + String get logCopied => 'Логи скопированы в буфер обмена'; @override - String get logSearchHint => 'Search logs...'; + String get logSearchHint => 'Поиск логов...'; @override - String get logFilterLevel => 'Level'; + String get logFilterLevel => 'Уровень'; @override - String get logFilterSection => 'Filter'; + String get logFilterSection => 'Фильтр'; @override - String get logShareLogs => 'Share logs'; + String get logShareLogs => 'Поделиться логами'; @override - String get logClearLogs => 'Clear logs'; + String get logClearLogs => 'Очистить логи'; @override - String get logClearLogsTitle => 'Clear Logs'; + String get logClearLogsTitle => 'Очистить логи'; @override - String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + String get logClearLogsMessage => 'Вы уверены, что хотите очистить все логи?'; @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; + String get logIspBlocking => 'ОБНАРУЖЕНА БЛОКИРОВКА ИНТЕРНЕТ ПРОВАЙДЕРОМ'; @override - String get logRateLimited => 'RATE LIMITED'; + String get logRateLimited => 'ОГРАНИЧЕННАЯ СКОРОСТЬ'; @override - String get logNetworkError => 'NETWORK ERROR'; + String get logNetworkError => 'ОШИБКА СЕТИ'; @override - String get logTrackNotFound => 'TRACK NOT FOUND'; + String get logTrackNotFound => 'ТРЕК НЕ НАЙДЕН'; @override - String get logFilterBySeverity => 'Filter logs by severity'; + String get logFilterBySeverity => 'Фильтровать логи по серьезности'; @override - String get logNoLogsYet => 'No logs yet'; + String get logNoLogsYet => 'Логов нет'; @override - String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + String get logNoLogsYetSubtitle => + 'Логи появятся здесь по мере использования приложения'; @override - String get logIssueSummary => 'Issue Summary'; + String get logIssueSummary => 'Краткое описание проблемы'; @override String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; + 'Ваш провайдер может блокировать доступ к сервисам скачивания'; @override String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + 'Попробуйте использовать VPN или измените DNS на 1.1.1.1 или 8.8.8.8'; @override - String get logRateLimitedDescription => 'Too many requests to the service'; + String get logRateLimitedDescription => 'Слишком много запросов к сервису'; @override String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; + 'Подождите несколько минут, прежде чем повторить попытку'; @override - String get logNetworkErrorDescription => 'Connection issues detected'; + String get logNetworkErrorDescription => 'Обнаружены проблемы с подключением'; @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; + String get logNetworkErrorSuggestion => 'Проверьте подключение к Интернету'; @override String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; + 'Некоторые треки не найдены в сервисах загрузки'; @override String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; + 'Трек может быть недоступен в lossless формате'; @override String logTotalErrors(int count) { - return 'Total errors: $count'; + return 'Всего ошибок: $count'; } @override String logAffected(String domains) { - return 'Affected: $domains'; + return 'Затронуто: $domains'; } @override String logEntriesFiltered(int count) { - return 'Entries ($count filtered)'; + return 'Записи ($count фильтровано)'; } @override String logEntries(int count) { - return 'Entries ($count)'; + return 'Записи ($count)'; } @override - String get credentialsTitle => 'Spotify Credentials'; + String get credentialsTitle => 'Учётные данные Spotify'; @override String get credentialsDescription => - 'Enter your Client ID and Secret to use your own Spotify application quota.'; + 'Введите свой Client ID и Secret, чтобы использовать собственные квоты в Spotify.'; @override String get credentialsClientId => 'Client ID'; @override - String get credentialsClientIdHint => 'Paste Client ID'; + String get credentialsClientIdHint => 'Вставьте Client ID'; @override String get credentialsClientSecret => 'Client Secret'; @override - String get credentialsClientSecretHint => 'Paste Client Secret'; + String get credentialsClientSecretHint => 'Вставьте Client Secret'; @override - String get channelStable => 'Stable'; + String get channelStable => 'Стабильный'; @override - String get channelPreview => 'Preview'; + String get channelPreview => 'Предварительный'; @override - String get sectionSearchSource => 'Search Source'; + String get sectionSearchSource => 'Поиск источника'; @override - String get sectionDownload => 'Download'; + String get sectionDownload => 'Скачивание'; @override - String get sectionPerformance => 'Performance'; + String get sectionPerformance => 'Производительность'; @override - String get sectionApp => 'App'; + String get sectionApp => 'Приложение'; @override - String get sectionData => 'Data'; + String get sectionData => 'Данные'; @override - String get sectionDebug => 'Debug'; + String get sectionDebug => 'Отладка'; @override - String get sectionService => 'Service'; + String get sectionService => 'Сервис'; @override - String get sectionAudioQuality => 'Audio Quality'; + String get sectionAudioQuality => 'Качество аудио'; @override - String get sectionFileSettings => 'File Settings'; + String get sectionFileSettings => 'Настройки файла'; @override - String get sectionColor => 'Color'; + String get sectionLyrics => 'Lyrics'; @override - String get sectionTheme => 'Theme'; + String get lyricsMode => 'Lyrics Mode'; @override - String get sectionLayout => 'Layout'; + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; @override - String get sectionLanguage => 'Language'; + String get lyricsModeEmbed => 'Embed in file'; @override - String get appearanceLanguage => 'App Language'; + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; + String get lyricsModeExternal => 'External .lrc file'; @override - String get settingsAppearanceSubtitle => 'Theme, colors, display'; + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; @override - String get settingsDownloadSubtitle => 'Service, quality, filename format'; + String get lyricsModeBoth => 'Both'; @override - String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; @override - String get settingsExtensionsSubtitle => 'Manage download providers'; + String get sectionColor => 'Цвет'; @override - String get settingsLogsSubtitle => 'View app logs for debugging'; + String get sectionTheme => 'Тема'; @override - String get loadingSharedLink => 'Loading shared link...'; + String get sectionLayout => 'Разметка'; @override - String get pressBackAgainToExit => 'Press back again to exit'; + String get sectionLanguage => 'Язык'; @override - String get tracksHeader => 'Tracks'; + String get appearanceLanguage => 'Язык приложения'; + + @override + String get appearanceLanguageSubtitle => 'Выберите предпочитаемый язык'; + + @override + String get settingsAppearanceSubtitle => 'Тема, цвета, дисплей'; + + @override + String get settingsDownloadSubtitle => + 'Сервисы, качество, формат имени файла'; + + @override + String get settingsOptionsSubtitle => + 'Резерв. сервер, тексты песен, обложки, обновления'; + + @override + String get settingsExtensionsSubtitle => 'Управление провайдерами скачивания'; + + @override + String get settingsLogsSubtitle => 'Просмотреть логи для отладки'; + + @override + String get loadingSharedLink => 'Загрузка общедоступной ссылки...'; + + @override + String get pressBackAgainToExit => 'Нажмите «Назад» ещё раз, чтобы выйти'; + + @override + String get tracksHeader => 'Треки'; @override String downloadAllCount(int count) { - return 'Download All ($count)'; + return 'Скачать все ($count)'; } @override @@ -1479,366 +1546,400 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count треков', + many: '$count треков', + few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @override - String get trackCopyFilePath => 'Copy file path'; + String get trackCopyFilePath => 'Скопировать путь к файлу'; @override - String get trackRemoveFromDevice => 'Remove from device'; + String get trackRemoveFromDevice => 'Удалить с устройства'; @override - String get trackLoadLyrics => 'Load Lyrics'; + String get trackLoadLyrics => 'Загрузить текст песни'; @override - String get trackMetadata => 'Metadata'; + String get trackMetadata => 'Метаданные'; @override - String get trackFileInfo => 'File Info'; + String get trackFileInfo => 'Информация о файле'; @override - String get trackLyrics => 'Lyrics'; + String get trackLyrics => 'Текст песни'; @override - String get trackFileNotFound => 'File not found'; + String get trackFileNotFound => 'Файл не найден'; @override - String get trackOpenInDeezer => 'Open in Deezer'; + String get trackOpenInDeezer => 'Открыть в Deezer'; @override - String get trackOpenInSpotify => 'Open in Spotify'; + String get trackOpenInSpotify => 'Открыть в Spotify'; @override - String get trackTrackName => 'Track name'; + String get trackTrackName => 'Название'; @override - String get trackArtist => 'Artist'; + String get trackArtist => 'Исполнитель'; @override - String get trackAlbumArtist => 'Album artist'; + String get trackAlbumArtist => 'Исполнитель альбома'; @override - String get trackAlbum => 'Album'; + String get trackAlbum => 'Альбом'; @override - String get trackTrackNumber => 'Track number'; + String get trackTrackNumber => 'Номер трека'; @override - String get trackDiscNumber => 'Disc number'; + String get trackDiscNumber => 'Номер диска'; @override - String get trackDuration => 'Duration'; + String get trackDuration => 'Продолжительность'; @override - String get trackAudioQuality => 'Audio quality'; + String get trackAudioQuality => 'Качество записи'; @override - String get trackReleaseDate => 'Release date'; + String get trackReleaseDate => 'Дата выхода'; @override - String get trackDownloaded => 'Downloaded'; + String get trackGenre => 'Genre'; @override - String get trackCopyLyrics => 'Copy lyrics'; + String get trackLabel => 'Label'; @override - String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + String get trackCopyright => 'Copyright'; @override - String get trackLyricsTimeout => 'Request timed out. Try again later.'; + String get trackDownloaded => 'Скачано'; @override - String get trackLyricsLoadFailed => 'Failed to load lyrics'; + String get trackCopyLyrics => 'Копировать текст'; @override - String get trackCopiedToClipboard => 'Copied to clipboard'; + String get trackLyricsNotAvailable => + 'Текст песни недоступен для этого трека'; @override - String get trackDeleteConfirmTitle => 'Remove from device?'; + String get trackLyricsTimeout => + 'Время ожидания запроса истекло. Повторите попытку позже.'; + + @override + String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни'; + + @override + String get trackCopiedToClipboard => 'Скопировано в буфер обмена'; + + @override + String get trackDeleteConfirmTitle => 'Удалить с устройства?'; @override String get trackDeleteConfirmMessage => - 'This will permanently delete the downloaded file and remove it from your history.'; + 'Это приведет к окончательному удалению загруженного файла и его удалению из истории.'; @override String trackCannotOpen(String message) { - return 'Cannot open: $message'; + return 'Невозможно открыть: $message'; } @override - String get dateToday => 'Today'; + String get dateToday => 'Сегодня'; @override - String get dateYesterday => 'Yesterday'; + String get dateYesterday => 'Вчера'; @override String dateDaysAgo(int count) { - return '$count days ago'; + return '$count дней назад'; } @override String dateWeeksAgo(int count) { - return '$count weeks ago'; + return '$count недель назад'; } @override String dateMonthsAgo(int count) { - return '$count months ago'; + return '$count месяцев назад'; } @override - String get concurrentSequential => 'Sequential'; + String get concurrentSequential => 'Последовательно'; @override - String get concurrentParallel2 => '2 Parallel'; + String get concurrentParallel2 => '2 параллельно'; @override - String get concurrentParallel3 => '3 Parallel'; + String get concurrentParallel3 => '3 параллельно'; @override - String get tapToSeeError => 'Tap to see error details'; + String get tapToSeeError => 'Нажмите, чтобы увидеть подробности ошибки'; @override - String get storeFilterAll => 'All'; + String get storeFilterAll => 'Все'; @override - String get storeFilterMetadata => 'Metadata'; + String get storeFilterMetadata => 'Метаданные'; @override - String get storeFilterDownload => 'Download'; + String get storeFilterDownload => 'Скачивание'; @override - String get storeFilterUtility => 'Utility'; + String get storeFilterUtility => 'Утилиты'; @override - String get storeFilterLyrics => 'Lyrics'; + String get storeFilterLyrics => 'Тексты песен'; @override - String get storeFilterIntegration => 'Integration'; + String get storeFilterIntegration => 'Интеграция'; @override - String get storeClearFilters => 'Clear filters'; + String get storeClearFilters => 'Очистить фильтры'; @override - String get storeNoResults => 'No extensions found'; + String get storeNoResults => 'Расширения не найдены'; @override - String get extensionProviderPriority => 'Provider Priority'; + String get extensionProviderPriority => 'Приоритет провайдера'; @override - String get extensionInstallButton => 'Install Extension'; + String get extensionInstallButton => 'Установить расширение'; @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)'; @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; + String get extensionDefaultProviderSubtitle => + 'Использовать встроенный поиск'; @override - String get extensionAuthor => 'Author'; + String get extensionAuthor => 'Автор'; @override String get extensionId => 'ID'; @override - String get extensionError => 'Error'; + String get extensionError => 'Ошибка'; @override - String get extensionCapabilities => 'Capabilities'; + String get extensionCapabilities => 'Возможности'; @override - String get extensionMetadataProvider => 'Metadata Provider'; + String get extensionMetadataProvider => 'Провайдер метаданных'; @override - String get extensionDownloadProvider => 'Download Provider'; + String get extensionDownloadProvider => 'Провайдер скачивания'; @override - String get extensionLyricsProvider => 'Lyrics Provider'; + String get extensionLyricsProvider => 'Провайдер текстов'; @override - String get extensionUrlHandler => 'URL Handler'; + String get extensionUrlHandler => 'URL-обработчик'; @override - String get extensionQualityOptions => 'Quality Options'; + String get extensionQualityOptions => 'Параметры качества'; @override - String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + String get extensionPostProcessingHooks => 'Хуки постобработки'; @override - String get extensionPermissions => 'Permissions'; + String get extensionPermissions => 'Разрешения'; @override - String get extensionSettings => 'Settings'; + String get extensionSettings => 'Настройки'; @override - String get extensionRemoveButton => 'Remove Extension'; + String get extensionRemoveButton => 'Удалить расширение'; @override - String get extensionUpdated => 'Updated'; + String get extensionUpdated => 'Обновлено'; @override - String get extensionMinAppVersion => 'Min App Version'; + String get extensionMinAppVersion => 'Мин. версия приложения'; @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; + String get extensionCustomTrackMatching => + 'Соответствие пользовательских треков'; @override - String get extensionPostProcessing => 'Post-Processing'; + String get extensionPostProcessing => 'Постобработка'; @override String extensionHooksAvailable(int count) { - return '$count hook(s) available'; + return 'Доступно $count хуков(ов)'; } @override String extensionPatternsCount(int count) { - return '$count pattern(s)'; + return '$count шаблон(ов)'; } @override String extensionStrategy(String strategy) { - return 'Strategy: $strategy'; + return 'Стратегия: $strategy'; } @override - String get extensionsProviderPrioritySection => 'Provider Priority'; + String get extensionsProviderPrioritySection => 'Приоритет провайдера'; @override - String get extensionsInstalledSection => 'Installed Extensions'; + String get extensionsInstalledSection => 'Установленные расширения'; @override - String get extensionsNoExtensions => 'No extensions installed'; + String get extensionsNoExtensions => 'Нет установленных расширений'; @override String get extensionsNoExtensionsSubtitle => - 'Install .spotiflac-ext files to add new providers'; + 'Установите .spotiflac-ext файлы для добавления новых провайдеров'; @override - String get extensionsInstallButton => 'Install Extension'; + String get extensionsInstallButton => 'Установить расширение'; @override String get extensionsInfoTip => - 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + 'Расширения могут добавлять новые метаданные и провайдеров загрузки. Устанавливайте только расширения из надежных источников.'; @override - String get extensionsInstalledSuccess => 'Extension installed successfully'; + String get extensionsInstalledSuccess => 'Расширение успешно установлено'; @override - String get extensionsDownloadPriority => 'Download Priority'; + String get extensionsDownloadPriority => 'Приоритет скачивания'; @override - String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + String get extensionsDownloadPrioritySubtitle => + 'Установка порядок сервисов скачивания'; @override String get extensionsNoDownloadProvider => - 'No extensions with download provider'; + 'Нет расширений с провайдером загрузки'; @override - String get extensionsMetadataPriority => 'Metadata Priority'; + String get extensionsMetadataPriority => 'Приоритет метаданных'; @override String get extensionsMetadataPrioritySubtitle => - 'Set search & metadata source order'; + 'Установка порядка поиска и источника метаданных'; @override String get extensionsNoMetadataProvider => - 'No extensions with metadata provider'; + 'Нет расширений с провайдером метаданных'; @override - String get extensionsSearchProvider => 'Search Provider'; + String get extensionsSearchProvider => 'Провайдер поиска'; @override - String get extensionsNoCustomSearch => 'No extensions with custom search'; + String get extensionsNoCustomSearch => + 'Нет расширений с пользовательским поиском'; @override String get extensionsSearchProviderDescription => - 'Choose which service to use for searching tracks'; + 'Выберите, какой сервис использовать для поиска треков'; @override - String get extensionsCustomSearch => 'Custom search'; + String get extensionsCustomSearch => 'Пользовательский поиск'; @override - String get extensionsErrorLoading => 'Error loading extension'; + String get extensionsErrorLoading => 'Ошибка загрузки расширения'; @override String get qualityFlacLossless => 'FLAC Lossless'; @override - String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + String get qualityFlacLosslessSubtitle => '16-бит / 44.1 кГц'; @override String get qualityHiResFlac => 'Hi-Res FLAC'; @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + String get qualityHiResFlacSubtitle => '24-бит / до 96кГц'; @override - String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + String get qualityHiResFlacMax => 'Hi-Res FLAC Макс.'; @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + 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 => - 'Actual quality depends on track availability from the service'; + 'Фактическое качество зависит от доступности треков в сервисе'; @override - String get downloadAskBeforeDownload => 'Ask Before Download'; + String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; @override - String get downloadDirectory => 'Download Directory'; + String get downloadDirectory => 'Папка для скачивания'; @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + String get downloadSeparateSinglesFolder => 'Отдельная папка для синглов'; @override - String get downloadAlbumFolderStructure => 'Album Folder Structure'; + String get downloadAlbumFolderStructure => 'Структура папок альбома'; @override - String get downloadSaveFormat => 'Save Format'; + String get downloadSaveFormat => 'Формат сохранения'; @override - String get downloadSelectService => 'Select Service'; + String get downloadSelectService => 'Выбор сервиса'; @override - String get downloadSelectQuality => 'Select Quality'; + String get downloadSelectQuality => 'Выбор качества'; @override - String get downloadFrom => 'Download From'; + String get downloadFrom => 'Скачивать из'; @override - String get downloadDefaultQualityLabel => 'Default Quality'; + String get downloadDefaultQualityLabel => 'Качество по умолчанию'; @override - String get downloadBestAvailable => 'Best available'; + String get downloadBestAvailable => 'Лучшее из доступных'; @override - String get folderNone => 'None'; + String get folderNone => 'Отсутствует'; @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; + String get folderNoneSubtitle => + 'Сохранить все файлы непосредственно в папку загрузки'; @override - String get folderArtist => 'Artist'; + String get folderArtist => 'Исполнитель'; @override - String get folderArtistSubtitle => 'Artist Name/filename'; + String get folderArtistSubtitle => 'Исполнитель/имя файла'; @override - String get folderAlbum => 'Album'; + String get folderAlbum => 'Альбом'; @override - String get folderAlbumSubtitle => 'Album Name/filename'; + String get folderAlbumSubtitle => 'Альбом/имя файла'; @override - String get folderArtistAlbum => 'Artist/Album'; + String get folderArtistAlbum => 'Исполнитель/Альбом'; @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; + String get folderArtistAlbumSubtitle => 'Исполнитель/ Альбом/имя файла'; @override String get serviceTidal => 'Tidal'; @@ -1856,145 +1957,156 @@ class AppLocalizationsRu extends AppLocalizations { String get serviceSpotify => 'Spotify'; @override - String get appearanceAmoledDark => 'AMOLED Dark'; + String get appearanceAmoledDark => 'AMOLED'; @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; + String get appearanceAmoledDarkSubtitle => 'Глубокий чёрный фон'; @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; + String get appearanceChooseAccentColor => 'Выберите акцентный цвет'; @override - String get appearanceChooseTheme => 'Theme Mode'; + String get appearanceChooseTheme => 'Режим темы'; @override - String get queueTitle => 'Download Queue'; + String get queueTitle => 'Очередь скачиваний'; @override - String get queueClearAll => 'Clear All'; + String get queueClearAll => 'Очистить всё'; @override String get queueClearAllMessage => - 'Are you sure you want to clear all downloads?'; + 'Вы уверены, что хотите очистить все загрузки?'; @override - String get queueEmpty => 'No downloads in queue'; + String get queueEmpty => 'Нет загрузок в очереди'; @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; + String get queueEmptySubtitle => 'Добавить треки с главного экрана'; @override - String get queueClearCompleted => 'Clear completed'; + String get queueClearCompleted => 'Очистка завершена'; @override - String get queueDownloadFailed => 'Download Failed'; + String get queueDownloadFailed => 'Ошибка скачивания'; @override - String get queueTrackLabel => 'Track:'; + String get queueTrackLabel => 'Трек:'; @override - String get queueArtistLabel => 'Artist:'; + String get queueArtistLabel => 'Исполнитель:'; @override - String get queueErrorLabel => 'Error:'; + String get queueErrorLabel => 'Ошибка:'; @override - String get queueUnknownError => 'Unknown error'; + String get queueUnknownError => 'Неизвестная ошибка'; @override - String get albumFolderArtistAlbum => 'Artist / Album'; + String get albumFolderArtistAlbum => 'Исполнитель / Альбом'; @override - String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + String get albumFolderArtistAlbumSubtitle => + 'Альбомы/Исполнитель/Название Альбома/'; @override - String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + String get albumFolderArtistYearAlbum => 'Исполнитель / [Год] Альбом'; @override String get albumFolderArtistYearAlbumSubtitle => - 'Albums/Artist Name/[2005] Album Name/'; + 'Альбомы/Исполнитель/[2005] Название Альбома/'; @override - String get albumFolderAlbumOnly => 'Album Only'; + String get albumFolderAlbumOnly => 'Только альбом'; @override - String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + String get albumFolderAlbumOnlySubtitle => 'Альбомы/Название Альбома/'; @override - String get albumFolderYearAlbum => '[Year] Album'; + String get albumFolderYearAlbum => '[Год] Альбом'; @override - String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + String get albumFolderYearAlbumSubtitle => + 'Альбомы/[2005] Название Альбома /'; @override - String get downloadedAlbumDeleteSelected => 'Delete Selected'; + String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; @override String downloadedAlbumDeleteMessage(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; } @override - String get downloadedAlbumTracksHeader => 'Tracks'; + String get downloadedAlbumTracksHeader => 'Треки'; @override String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; + return '$count скачано'; } @override String downloadedAlbumSelectedCount(int count) { - return '$count selected'; + return '$count выбрано'; } @override - String get downloadedAlbumAllSelected => 'All tracks selected'; + String get downloadedAlbumAllSelected => 'Все треки выбраны'; @override - String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + String get downloadedAlbumTapToSelect => 'Нажмите на треки для выбора'; @override String downloadedAlbumDeleteCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Delete $count $_temp0'; + return 'Удалить $count $_temp0'; } @override - String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления'; @override - String get utilityFunctions => 'Utility Functions'; + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } @override - String get recentTypeArtist => 'Artist'; + String get utilityFunctions => 'Функции утилиты'; @override - String get recentTypeAlbum => 'Album'; + String get recentTypeArtist => 'Исполнитель'; @override - String get recentTypeSong => 'Song'; + String get recentTypeAlbum => 'Альбом'; @override - String get recentTypePlaylist => 'Playlist'; + String get recentTypeSong => 'Песня'; + + @override + String get recentTypePlaylist => 'Плейлист'; @override String recentPlaylistInfo(String name) { - return 'Playlist: $name'; + return 'Плейлист: $name'; } @override String errorGeneric(String message) { - return 'Error: $message'; + return 'Ошибка: $message'; } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a5ab5a63..30835ee2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -398,6 +398,2068 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + @override + String get aboutLogoArtist => + 'The talented artist who created our beautiful app logo!'; + + @override + String get aboutTranslators => 'Translators'; + + @override + String get aboutSpecialThanks => 'Special Thanks'; + + @override + String get aboutLinks => 'Links'; + + @override + String get aboutMobileSource => 'Mobile source code'; + + @override + String get aboutPCSource => 'PC source code'; + + @override + String get aboutReportIssue => 'Report an issue'; + + @override + String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + + @override + String get aboutFeatureRequest => 'Feature request'; + + @override + String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + + @override + String get aboutSupport => 'Support'; + + @override + String get aboutBuyMeCoffee => 'Buy me a coffee'; + + @override + String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + + @override + String get aboutApp => 'App'; + + @override + String get aboutVersion => 'Version'; + + @override + String get aboutBinimumDesc => + 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + + @override + String get aboutSachinsenalDesc => + 'The original HiFi project creator. The foundation of Tidal integration!'; + + @override + String get aboutDoubleDouble => 'DoubleDouble'; + + @override + String get aboutDoubleDoubleDesc => + 'Amazing API for Amazon Music downloads. Thank you for making it free!'; + + @override + String get aboutDabMusic => 'DAB Music'; + + @override + String get aboutDabMusicDesc => + 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + + @override + String get aboutAppDescription => + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + + @override + String get albumTitle => 'Album'; + + @override + String albumTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get albumDownloadAll => 'Download All'; + + @override + String get albumDownloadRemaining => 'Download Remaining'; + + @override + String get playlistTitle => 'Playlist'; + + @override + String get artistTitle => 'Artist'; + + @override + String get artistAlbums => 'Albums'; + + @override + String get artistSingles => 'Singles & EPs'; + + @override + String get artistCompilations => 'Compilations'; + + @override + String artistReleases(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count releases', + one: '1 release', + ); + return '$_temp0'; + } + + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + + @override + String get trackMetadataTitle => 'Track Info'; + + @override + String get trackMetadataArtist => 'Artist'; + + @override + String get trackMetadataAlbum => 'Album'; + + @override + String get trackMetadataDuration => 'Duration'; + + @override + String get trackMetadataQuality => 'Quality'; + + @override + String get trackMetadataPath => 'File Path'; + + @override + String get trackMetadataDownloadedAt => 'Downloaded'; + + @override + String get trackMetadataService => 'Service'; + + @override + String get trackMetadataPlay => 'Play'; + + @override + String get trackMetadataShare => 'Share'; + + @override + String get trackMetadataDelete => 'Delete'; + + @override + String get trackMetadataRedownload => 'Re-download'; + + @override + String get trackMetadataOpenFolder => 'Open Folder'; + + @override + String get setupTitle => 'Welcome to SpotiFLAC'; + + @override + String get setupSubtitle => 'Let\'s get you started'; + + @override + String get setupStoragePermission => 'Storage Permission'; + + @override + String get setupStoragePermissionSubtitle => + 'Required to save downloaded files'; + + @override + String get setupStoragePermissionGranted => 'Permission granted'; + + @override + String get setupStoragePermissionDenied => 'Permission denied'; + + @override + String get setupGrantPermission => 'Grant Permission'; + + @override + String get setupDownloadLocation => 'Download Location'; + + @override + String get setupChooseFolder => 'Choose Folder'; + + @override + String get setupContinue => 'Continue'; + + @override + String get setupSkip => 'Skip for now'; + + @override + String get setupStorageAccessRequired => 'Storage Access Required'; + + @override + String get setupStorageAccessMessage => + 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + + @override + String get setupOpenSettings => 'Open Settings'; + + @override + String get setupPermissionDeniedMessage => + 'Permission denied. Please grant all permissions to continue.'; + + @override + String setupPermissionRequired(String permissionType) { + return '$permissionType Permission Required'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + } + + @override + String get setupSelectDownloadFolder => 'Select Download Folder'; + + @override + String get setupUseDefaultFolder => 'Use Default Folder?'; + + @override + String get setupNoFolderSelected => + 'No folder selected. Would you like to use the default Music folder?'; + + @override + String get setupUseDefault => 'Use Default'; + + @override + String get setupDownloadLocationTitle => 'Download Location'; + + @override + String get setupDownloadLocationIosMessage => + 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + + @override + String get setupAppDocumentsFolder => 'App Documents Folder'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recommended - accessible via Files app'; + + @override + String get setupChooseFromFiles => 'Choose from Files'; + + @override + String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + + @override + String get setupIosEmptyFolderWarning => + 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + + @override + String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + + @override + String get setupStepStorage => 'Storage'; + + @override + String get setupStepNotification => 'Notification'; + + @override + String get setupStepFolder => 'Folder'; + + @override + String get setupStepSpotify => 'Spotify'; + + @override + String get setupStepPermission => 'Permission'; + + @override + String get setupStorageGranted => 'Storage Permission Granted!'; + + @override + String get setupStorageRequired => 'Storage Permission Required'; + + @override + String get setupStorageDescription => + 'SpotiFLAC needs storage permission to save your downloaded music files.'; + + @override + String get setupNotificationGranted => 'Notification Permission Granted!'; + + @override + String get setupNotificationEnable => 'Enable Notifications'; + + @override + String get setupNotificationDescription => + 'Get notified when downloads complete or require attention.'; + + @override + String get setupFolderSelected => 'Download Folder Selected!'; + + @override + String get setupFolderChoose => 'Choose Download Folder'; + + @override + String get setupFolderDescription => + 'Select a folder where your downloaded music will be saved.'; + + @override + String get setupChangeFolder => 'Change Folder'; + + @override + String get setupSelectFolder => 'Select Folder'; + + @override + String get setupSpotifyApiOptional => 'Spotify API (Optional)'; + + @override + String get setupSpotifyApiDescription => + 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; + + @override + String get setupUseSpotifyApi => 'Use Spotify API'; + + @override + String get setupEnterCredentialsBelow => 'Enter your credentials below'; + + @override + String get setupUsingDeezer => 'Using Deezer (no account needed)'; + + @override + String get setupEnterClientId => 'Enter Spotify Client ID'; + + @override + String get setupEnterClientSecret => 'Enter Spotify Client Secret'; + + @override + String get setupGetFreeCredentials => + 'Get your free API credentials from the Spotify Developer Dashboard.'; + + @override + String get setupEnableNotifications => 'Enable Notifications'; + + @override + String get setupProceedToNextStep => 'You can now proceed to the next step.'; + + @override + String get setupNotificationProgressDescription => + 'You will receive download progress notifications.'; + + @override + String get setupNotificationBackgroundDescription => + 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + + @override + String get setupSkipForNow => 'Skip for now'; + + @override + String get setupBack => 'Back'; + + @override + String get setupNext => 'Next'; + + @override + String get setupGetStarted => 'Get Started'; + + @override + String get setupSkipAndStart => 'Skip & Start'; + + @override + String get setupAllowAccessToManageFiles => + 'Please enable \"Allow access to manage all files\" in the next screen.'; + + @override + String get setupGetCredentialsFromSpotify => + 'Get credentials from developer.spotify.com'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogOk => 'OK'; + + @override + String get dialogSave => 'Save'; + + @override + String get dialogDelete => 'Delete'; + + @override + String get dialogRetry => 'Retry'; + + @override + String get dialogClose => 'Close'; + + @override + String get dialogYes => 'Yes'; + + @override + String get dialogNo => 'No'; + + @override + String get dialogClear => 'Clear'; + + @override + String get dialogConfirm => 'Confirm'; + + @override + String get dialogDone => 'Done'; + + @override + String get dialogImport => 'Import'; + + @override + String get dialogDiscard => 'Discard'; + + @override + String get dialogRemove => 'Remove'; + + @override + String get dialogUninstall => 'Uninstall'; + + @override + String get dialogDiscardChanges => 'Discard Changes?'; + + @override + String get dialogUnsavedChanges => + 'You have unsaved changes. Do you want to discard them?'; + + @override + String get dialogDownloadFailed => 'Download Failed'; + + @override + String get dialogTrackLabel => 'Track:'; + + @override + String get dialogArtistLabel => 'Artist:'; + + @override + String get dialogErrorLabel => 'Error:'; + + @override + String get dialogClearAll => 'Clear All'; + + @override + String get dialogClearAllDownloads => + 'Are you sure you want to clear all downloads?'; + + @override + String get dialogRemoveFromDevice => 'Remove from device?'; + + @override + String get dialogRemoveExtension => 'Remove Extension'; + + @override + String get dialogRemoveExtensionMessage => + 'Are you sure you want to remove this extension? This cannot be undone.'; + + @override + String get dialogUninstallExtension => 'Uninstall Extension?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return 'Are you sure you want to remove $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Clear History'; + + @override + String get dialogClearHistoryMessage => + 'Are you sure you want to clear all download history? This cannot be undone.'; + + @override + String get dialogDeleteSelectedTitle => 'Delete Selected'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + } + + @override + String get dialogImportPlaylistTitle => 'Import Playlist'; + + @override + String dialogImportPlaylistMessage(int count) { + 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'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" already downloaded'; + } + + @override + String get snackbarHistoryCleared => 'History cleared'; + + @override + String get snackbarCredentialsSaved => 'Credentials saved'; + + @override + String get snackbarCredentialsCleared => 'Credentials cleared'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Deleted $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'Cannot open file: $error'; + } + + @override + String get snackbarFillAllFields => 'Please fill all fields'; + + @override + String get snackbarViewQueue => 'View Queue'; + + @override + String snackbarFailedToLoad(String error) { + return 'Failed to load: $error'; + } + + @override + String snackbarUrlCopied(String platform) { + return '$platform URL copied to clipboard'; + } + + @override + String get snackbarFileNotFound => 'File not found'; + + @override + String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + + @override + String get snackbarProviderPrioritySaved => 'Provider priority saved'; + + @override + String get snackbarMetadataProviderSaved => + 'Metadata provider priority saved'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName installed.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName updated.'; + } + + @override + String get snackbarFailedToInstall => 'Failed to install extension'; + + @override + String get snackbarFailedToUpdate => 'Failed to update extension'; + + @override + String get errorRateLimited => 'Rate Limited'; + + @override + String get errorRateLimitedMessage => + 'Too many requests. Please wait a moment before searching again.'; + + @override + String errorFailedToLoad(String item) { + return 'Failed to load $item'; + } + + @override + String get errorNoTracksFound => 'No tracks found'; + + @override + String errorMissingExtensionSource(String item) { + return 'Cannot load $item: missing extension source'; + } + + @override + String get statusQueued => 'Queued'; + + @override + String get statusDownloading => 'Downloading'; + + @override + String get statusFinalizing => 'Finalizing'; + + @override + String get statusCompleted => 'Completed'; + + @override + String get statusFailed => 'Failed'; + + @override + String get statusSkipped => 'Skipped'; + + @override + String get statusPaused => 'Paused'; + + @override + String get actionPause => 'Pause'; + + @override + String get actionResume => 'Resume'; + + @override + String get actionCancel => 'Cancel'; + + @override + String get actionStop => 'Stop'; + + @override + String get actionSelect => 'Select'; + + @override + String get actionSelectAll => 'Select All'; + + @override + String get actionDeselect => 'Deselect'; + + @override + String get actionPaste => 'Paste'; + + @override + String get actionImportCsv => 'Import CSV'; + + @override + String get actionRemoveCredentials => 'Remove Credentials'; + + @override + String get actionSaveCredentials => 'Save Credentials'; + + @override + String selectionSelected(int count) { + return '$count selected'; + } + + @override + String get selectionAllSelected => 'All tracks selected'; + + @override + String get selectionTapToSelect => 'Tap tracks to select'; + + @override + String selectionDeleteTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get selectionSelectToDelete => 'Select tracks to delete'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Fetching metadata... $current/$total'; + } + + @override + String get progressReadingCsv => 'Reading CSV...'; + + @override + String get searchSongs => 'Songs'; + + @override + String get searchArtists => 'Artists'; + + @override + String get searchAlbums => 'Albums'; + + @override + String get searchPlaylists => 'Playlists'; + + @override + String get tooltipPlay => 'Play'; + + @override + String get tooltipCancel => 'Cancel'; + + @override + String get tooltipStop => 'Stop'; + + @override + String get tooltipRetry => 'Retry'; + + @override + String get tooltipRemove => 'Remove'; + + @override + String get tooltipClear => 'Clear'; + + @override + String get tooltipPaste => 'Paste'; + + @override + String get filenameFormat => 'Filename Format'; + + @override + String filenameFormatPreview(String preview) { + return 'Preview: $preview'; + } + + @override + String get filenameAvailablePlaceholders => 'Available placeholders:'; + + @override + String filenameHint(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get folderOrganization => 'Folder Organization'; + + @override + String get folderOrganizationNone => 'No organization'; + + @override + String get folderOrganizationByArtist => 'By Artist'; + + @override + String get folderOrganizationByAlbum => 'By Album'; + + @override + String get folderOrganizationByArtistAlbum => 'Artist/Album'; + + @override + String get folderOrganizationDescription => + 'Organize downloaded files into folders'; + + @override + String get folderOrganizationNoneSubtitle => 'All files in download folder'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Separate folder for each artist'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Separate folder for each album'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Nested folders for artist and album'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String updateNewVersion(String version) { + return 'Version $version is available'; + } + + @override + String get updateDownload => 'Download'; + + @override + String get updateLater => 'Later'; + + @override + String get updateChangelog => 'Changelog'; + + @override + String get updateStartingDownload => 'Starting download...'; + + @override + String get updateDownloadFailed => 'Download failed'; + + @override + String get updateFailedMessage => 'Failed to download update'; + + @override + String get updateNewVersionReady => 'A new version is ready'; + + @override + String get updateCurrent => 'Current'; + + @override + String get updateNew => 'New'; + + @override + String get updateDownloading => 'Downloading...'; + + @override + String get updateWhatsNew => 'What\'s New'; + + @override + String get updateDownloadInstall => 'Download & Install'; + + @override + String get updateDontRemind => 'Don\'t remind'; + + @override + String get providerPriority => 'Provider Priority'; + + @override + String get providerPrioritySubtitle => 'Drag to reorder download providers'; + + @override + String get providerPriorityTitle => 'Provider Priority'; + + @override + String get providerPriorityDescription => + 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; + + @override + String get providerPriorityInfo => + 'If a track is not available on the first provider, the app will automatically try the next one.'; + + @override + String get providerBuiltIn => 'Built-in'; + + @override + String get providerExtension => 'Extension'; + + @override + String get metadataProviderPriority => 'Metadata Provider Priority'; + + @override + String get metadataProviderPrioritySubtitle => + 'Order used when fetching track metadata'; + + @override + String get metadataProviderPriorityTitle => 'Metadata Priority'; + + @override + String get metadataProviderPriorityDescription => + 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; + + @override + String get metadataProviderPriorityInfo => + 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; + + @override + String get metadataNoRateLimits => 'No rate limits'; + + @override + String get metadataMayRateLimit => 'May rate limit'; + + @override + String get logTitle => 'Logs'; + + @override + String get logCopy => 'Copy Logs'; + + @override + String get logClear => 'Clear Logs'; + + @override + String get logShare => 'Share Logs'; + + @override + String get logEmpty => 'No logs yet'; + + @override + String get logCopied => 'Logs copied to clipboard'; + + @override + String get logSearchHint => 'Search logs...'; + + @override + String get logFilterLevel => 'Level'; + + @override + String get logFilterSection => 'Filter'; + + @override + String get logShareLogs => 'Share logs'; + + @override + String get logClearLogs => 'Clear logs'; + + @override + String get logClearLogsTitle => 'Clear Logs'; + + @override + String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + + @override + String get logIspBlocking => 'ISP BLOCKING DETECTED'; + + @override + String get logRateLimited => 'RATE LIMITED'; + + @override + String get logNetworkError => 'NETWORK ERROR'; + + @override + String get logTrackNotFound => 'TRACK NOT FOUND'; + + @override + String get logFilterBySeverity => 'Filter logs by severity'; + + @override + String get logNoLogsYet => 'No logs yet'; + + @override + String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + + @override + String get logIssueSummary => 'Issue Summary'; + + @override + String get logIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logRateLimitedDescription => 'Too many requests to the service'; + + @override + String get logRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String logTotalErrors(int count) { + return 'Total errors: $count'; + } + + @override + String logAffected(String domains) { + return 'Affected: $domains'; + } + + @override + String logEntriesFiltered(int count) { + return 'Entries ($count filtered)'; + } + + @override + String logEntries(int count) { + return 'Entries ($count)'; + } + + @override + String get credentialsTitle => 'Spotify Credentials'; + + @override + String get credentialsDescription => + 'Enter your Client ID and Secret to use your own Spotify application quota.'; + + @override + String get credentialsClientId => 'Client ID'; + + @override + String get credentialsClientIdHint => 'Paste Client ID'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Paste Client Secret'; + + @override + String get channelStable => 'Stable'; + + @override + String get channelPreview => 'Preview'; + + @override + String get sectionSearchSource => 'Search Source'; + + @override + String get sectionDownload => 'Download'; + + @override + String get sectionPerformance => 'Performance'; + + @override + String get sectionApp => 'App'; + + @override + String get sectionData => 'Data'; + + @override + String get sectionDebug => 'Debug'; + + @override + String get sectionService => 'Service'; + + @override + String get sectionAudioQuality => 'Audio Quality'; + + @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'; + + @override + String get sectionTheme => 'Theme'; + + @override + String get sectionLayout => 'Layout'; + + @override + String get sectionLanguage => 'Language'; + + @override + String get appearanceLanguage => 'App Language'; + + @override + String get appearanceLanguageSubtitle => 'Choose your preferred language'; + + @override + String get settingsAppearanceSubtitle => 'Theme, colors, display'; + + @override + String get settingsDownloadSubtitle => 'Service, quality, filename format'; + + @override + String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + + @override + String get settingsExtensionsSubtitle => 'Manage download providers'; + + @override + String get settingsLogsSubtitle => 'View app logs for debugging'; + + @override + String get loadingSharedLink => 'Loading shared link...'; + + @override + String get pressBackAgainToExit => 'Press back again to exit'; + + @override + String get tracksHeader => 'Tracks'; + + @override + String downloadAllCount(int count) { + return 'Download All ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copy file path'; + + @override + String get trackRemoveFromDevice => 'Remove from device'; + + @override + String get trackLoadLyrics => 'Load Lyrics'; + + @override + String get trackMetadata => 'Metadata'; + + @override + String get trackFileInfo => 'File Info'; + + @override + String get trackLyrics => 'Lyrics'; + + @override + String get trackFileNotFound => 'File not found'; + + @override + String get trackOpenInDeezer => 'Open in Deezer'; + + @override + String get trackOpenInSpotify => 'Open in Spotify'; + + @override + String get trackTrackName => 'Track name'; + + @override + String get trackArtist => 'Artist'; + + @override + String get trackAlbumArtist => 'Album artist'; + + @override + String get trackAlbum => 'Album'; + + @override + String get trackTrackNumber => 'Track number'; + + @override + String get trackDiscNumber => 'Disc number'; + + @override + String get trackDuration => 'Duration'; + + @override + String get trackAudioQuality => 'Audio quality'; + + @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'; + + @override + String get trackCopyLyrics => 'Copy lyrics'; + + @override + String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + + @override + String get trackLyricsTimeout => 'Request timed out. Try again later.'; + + @override + String get trackLyricsLoadFailed => 'Failed to load lyrics'; + + @override + String get trackCopiedToClipboard => 'Copied to clipboard'; + + @override + String get trackDeleteConfirmTitle => 'Remove from device?'; + + @override + String get trackDeleteConfirmMessage => + 'This will permanently delete the downloaded file and remove it from your history.'; + + @override + String trackCannotOpen(String message) { + return 'Cannot open: $message'; + } + + @override + String get dateToday => 'Today'; + + @override + String get dateYesterday => 'Yesterday'; + + @override + String dateDaysAgo(int count) { + return '$count days ago'; + } + + @override + String dateWeeksAgo(int count) { + return '$count weeks ago'; + } + + @override + String dateMonthsAgo(int count) { + return '$count months ago'; + } + + @override + String get concurrentSequential => 'Sequential'; + + @override + String get concurrentParallel2 => '2 Parallel'; + + @override + String get concurrentParallel3 => '3 Parallel'; + + @override + String get tapToSeeError => 'Tap to see error details'; + + @override + String get storeFilterAll => 'All'; + + @override + String get storeFilterMetadata => 'Metadata'; + + @override + String get storeFilterDownload => 'Download'; + + @override + String get storeFilterUtility => 'Utility'; + + @override + String get storeFilterLyrics => 'Lyrics'; + + @override + String get storeFilterIntegration => 'Integration'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get storeNoResults => 'No extensions found'; + + @override + String get extensionProviderPriority => 'Provider Priority'; + + @override + String get extensionInstallButton => 'Install Extension'; + + @override + String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Use built-in search'; + + @override + String get extensionAuthor => 'Author'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Capabilities'; + + @override + String get extensionMetadataProvider => 'Metadata Provider'; + + @override + String get extensionDownloadProvider => 'Download Provider'; + + @override + String get extensionLyricsProvider => 'Lyrics Provider'; + + @override + String get extensionUrlHandler => 'URL Handler'; + + @override + String get extensionQualityOptions => 'Quality Options'; + + @override + String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + + @override + String get extensionPermissions => 'Permissions'; + + @override + String get extensionSettings => 'Settings'; + + @override + String get extensionRemoveButton => 'Remove Extension'; + + @override + String get extensionUpdated => 'Updated'; + + @override + String get extensionMinAppVersion => 'Min App Version'; + + @override + String get extensionCustomTrackMatching => 'Custom Track Matching'; + + @override + String get extensionPostProcessing => 'Post-Processing'; + + @override + String extensionHooksAvailable(int count) { + return '$count hook(s) available'; + } + + @override + String extensionPatternsCount(int count) { + return '$count pattern(s)'; + } + + @override + String extensionStrategy(String strategy) { + return 'Strategy: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Provider Priority'; + + @override + String get extensionsInstalledSection => 'Installed Extensions'; + + @override + String get extensionsNoExtensions => 'No extensions installed'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Install .spotiflac-ext files to add new providers'; + + @override + String get extensionsInstallButton => 'Install Extension'; + + @override + String get extensionsInfoTip => + 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + + @override + String get extensionsInstalledSuccess => 'Extension installed successfully'; + + @override + String get extensionsDownloadPriority => 'Download Priority'; + + @override + String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + + @override + String get extensionsNoDownloadProvider => + 'No extensions with download provider'; + + @override + String get extensionsMetadataPriority => 'Metadata Priority'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Set search & metadata source order'; + + @override + String get extensionsNoMetadataProvider => + 'No extensions with metadata provider'; + + @override + String get extensionsSearchProvider => 'Search Provider'; + + @override + String get extensionsNoCustomSearch => 'No extensions with custom search'; + + @override + String get extensionsSearchProviderDescription => + 'Choose which service to use for searching tracks'; + + @override + String get extensionsCustomSearch => 'Custom search'; + + @override + String get extensionsErrorLoading => 'Error loading extension'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @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'; + + @override + String get downloadAskBeforeDownload => 'Ask Before Download'; + + @override + String get downloadDirectory => 'Download Directory'; + + @override + String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + + @override + String get downloadAlbumFolderStructure => 'Album Folder Structure'; + + @override + String get downloadSaveFormat => 'Save Format'; + + @override + String get downloadSelectService => 'Select Service'; + + @override + String get downloadSelectQuality => 'Select Quality'; + + @override + String get downloadFrom => 'Download From'; + + @override + String get downloadDefaultQualityLabel => 'Default Quality'; + + @override + String get downloadBestAvailable => 'Best available'; + + @override + String get folderNone => 'None'; + + @override + String get folderNoneSubtitle => 'Save all files directly to download folder'; + + @override + String get folderArtist => 'Artist'; + + @override + String get folderArtistSubtitle => 'Artist Name/filename'; + + @override + String get folderAlbum => 'Album'; + + @override + String get folderAlbumSubtitle => 'Album Name/filename'; + + @override + String get folderArtistAlbum => 'Artist/Album'; + + @override + String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; + + @override + String get serviceTidal => 'Tidal'; + + @override + String get serviceQobuz => 'Qobuz'; + + @override + String get serviceAmazon => 'Amazon'; + + @override + String get serviceDeezer => 'Deezer'; + + @override + String get serviceSpotify => 'Spotify'; + + @override + String get appearanceAmoledDark => 'AMOLED Dark'; + + @override + String get appearanceAmoledDarkSubtitle => 'Pure black background'; + + @override + String get appearanceChooseAccentColor => 'Choose Accent Color'; + + @override + String get appearanceChooseTheme => 'Theme Mode'; + + @override + String get queueTitle => 'Download Queue'; + + @override + String get queueClearAll => 'Clear All'; + + @override + String get queueClearAllMessage => + 'Are you sure you want to clear all downloads?'; + + @override + String get queueEmpty => 'No downloads in queue'; + + @override + String get queueEmptySubtitle => 'Add tracks from the home screen'; + + @override + String get queueClearCompleted => 'Clear completed'; + + @override + String get queueDownloadFailed => 'Download Failed'; + + @override + String get queueTrackLabel => 'Track:'; + + @override + String get queueArtistLabel => 'Artist:'; + + @override + String get queueErrorLabel => 'Error:'; + + @override + String get queueUnknownError => 'Unknown error'; + + @override + String get albumFolderArtistAlbum => 'Artist / Album'; + + @override + String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + + @override + String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Albums/Artist Name/[2005] Album Name/'; + + @override + String get albumFolderAlbumOnly => 'Album Only'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + + @override + String get albumFolderYearAlbum => '[Year] Album'; + + @override + String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + + @override + String get downloadedAlbumDeleteSelected => 'Delete Selected'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + } + + @override + String get downloadedAlbumTracksHeader => 'Tracks'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count selected'; + } + + @override + String get downloadedAlbumAllSelected => 'All tracks selected'; + + @override + String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + + @override + String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } +} + +/// The translations for Chinese, as used in China (`zh_CN`). +class AppLocalizationsZhCn extends AppLocalizationsZh { + AppLocalizationsZhCn() : super('zh_CN'); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get appDescription => + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + + @override + String get navHome => 'Home'; + + @override + String get navHistory => 'History'; + + @override + String get navSettings => 'Settings'; + + @override + String get navStore => 'Store'; + + @override + String get homeTitle => 'Home'; + + @override + String get homeSearchHint => 'Paste Spotify URL or search...'; + + @override + String homeSearchHintExtension(String extensionName) { + return 'Search with $extensionName...'; + } + + @override + String get homeSubtitle => 'Paste a Spotify link or search by name'; + + @override + String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + + @override + String get homeRecent => 'Recent'; + + @override + String get historyTitle => 'History'; + + @override + String historyDownloading(int count) { + return 'Downloading ($count)'; + } + + @override + String get historyDownloaded => 'Downloaded'; + + @override + String get historyFilterAll => 'All'; + + @override + String get historyFilterAlbums => 'Albums'; + + @override + String get historyFilterSingles => 'Singles'; + + @override + String historyTracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String historyAlbumsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get historyNoDownloads => 'No download history'; + + @override + String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; + + @override + String get historyNoAlbums => 'No album downloads'; + + @override + String get historyNoAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get historyNoSingles => 'No single downloads'; + + @override + String get historyNoSinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsDownload => 'Download'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsOptions => 'Options'; + + @override + String get settingsExtensions => 'Extensions'; + + @override + String get settingsAbout => 'About'; + + @override + String get downloadTitle => 'Download'; + + @override + String get downloadLocation => 'Download Location'; + + @override + String get downloadLocationSubtitle => 'Choose where to save files'; + + @override + String get downloadLocationDefault => 'Default location'; + + @override + String get downloadDefaultService => 'Default Service'; + + @override + String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + + @override + String get downloadDefaultQuality => 'Default Quality'; + + @override + String get downloadAskQuality => 'Ask Quality Before Download'; + + @override + String get downloadAskQualitySubtitle => + 'Show quality picker for each download'; + + @override + String get downloadFilenameFormat => 'Filename Format'; + + @override + String get downloadFolderOrganization => 'Folder Organization'; + + @override + String get downloadSeparateSingles => 'Separate Singles'; + + @override + String get downloadSeparateSinglesSubtitle => + 'Put single tracks in a separate folder'; + + @override + String get qualityBest => 'Best Available'; + + @override + String get qualityFlac => 'FLAC'; + + @override + String get quality320 => '320 kbps'; + + @override + String get quality128 => '128 kbps'; + + @override + String get appearanceTitle => 'Appearance'; + + @override + String get appearanceTheme => 'Theme'; + + @override + String get appearanceThemeSystem => 'System'; + + @override + String get appearanceThemeLight => 'Light'; + + @override + String get appearanceThemeDark => 'Dark'; + + @override + String get appearanceDynamicColor => 'Dynamic Color'; + + @override + String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + + @override + String get appearanceAccentColor => 'Accent Color'; + + @override + String get appearanceHistoryView => 'History View'; + + @override + String get appearanceHistoryViewList => 'List'; + + @override + String get appearanceHistoryViewGrid => 'Grid'; + + @override + String get optionsTitle => 'Options'; + + @override + String get optionsSearchSource => 'Search Source'; + + @override + String get optionsPrimaryProvider => 'Primary Provider'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Service used when searching by track name.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Using extension: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Tap Deezer or Spotify to switch back from extension'; + + @override + String get optionsAutoFallback => 'Auto Fallback'; + + @override + String get optionsAutoFallbackSubtitle => + 'Try other services if download fails'; + + @override + String get optionsUseExtensionProviders => 'Use Extension Providers'; + + @override + String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + + @override + String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + + @override + String get optionsEmbedLyrics => 'Embed Lyrics'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Embed synced lyrics into FLAC files'; + + @override + String get optionsMaxQualityCover => 'Max Quality Cover'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Download highest resolution cover art'; + + @override + String get optionsConcurrentDownloads => 'Concurrent Downloads'; + + @override + String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count parallel downloads'; + } + + @override + String get optionsConcurrentWarning => + 'Parallel downloads may trigger rate limiting'; + + @override + String get optionsExtensionStore => 'Extension Store'; + + @override + String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + + @override + String get optionsCheckUpdates => 'Check for Updates'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notify when new version is available'; + + @override + String get optionsUpdateChannel => 'Update Channel'; + + @override + String get optionsUpdateChannelStable => 'Stable releases only'; + + @override + String get optionsUpdateChannelPreview => 'Get preview releases'; + + @override + String get optionsUpdateChannelWarning => + 'Preview may contain bugs or incomplete features'; + + @override + String get optionsClearHistory => 'Clear Download History'; + + @override + String get optionsClearHistorySubtitle => + 'Remove all downloaded tracks from history'; + + @override + String get optionsDetailedLogging => 'Detailed Logging'; + + @override + String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + + @override + String get optionsDetailedLoggingOff => 'Enable for bug reports'; + + @override + String get optionsSpotifyCredentials => 'Spotify Credentials'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'Client ID: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + + @override + String get optionsSpotifyWarning => + 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + + @override + String get extensionsTitle => 'Extensions'; + + @override + String get extensionsInstalled => 'Installed Extensions'; + + @override + String get extensionsNone => 'No extensions installed'; + + @override + String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + + @override + String get extensionsEnabled => 'Enabled'; + + @override + String get extensionsDisabled => 'Disabled'; + + @override + String extensionsVersion(String version) { + return 'Version $version'; + } + + @override + String extensionsAuthor(String author) { + return 'by $author'; + } + + @override + String get extensionsUninstall => 'Uninstall'; + + @override + String get extensionsSetAsSearch => 'Set as Search Provider'; + + @override + String get storeTitle => 'Extension Store'; + + @override + String get storeSearch => 'Search extensions...'; + + @override + String get storeInstall => 'Install'; + + @override + String get storeInstalled => 'Installed'; + + @override + String get storeUpdate => 'Update'; + + @override + String get aboutTitle => 'About'; + + @override + String get aboutContributors => 'Contributors'; + + @override + String get aboutMobileDeveloper => 'Mobile version developer'; + + @override + String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + @override String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; @@ -1999,9 +4061,9 @@ class AppLocalizationsZh extends AppLocalizations { } } -/// The translations for Chinese, as used in China (`zh_CN`). -class AppLocalizationsZhCn extends AppLocalizationsZh { - AppLocalizationsZhCn() : super('zh_CN'); +/// The translations for Chinese, as used in Taiwan (`zh_TW`). +class AppLocalizationsZhTw extends AppLocalizationsZh { + AppLocalizationsZhTw() : super('zh_TW'); @override String get appName => 'SpotiFLAC'; @@ -2040,7 +4102,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @override - String get homeRecent => 'Recent'; + String get homeRecent => '最新的'; @override String get historyTitle => 'History'; @@ -2508,6 +4570,14 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -3962,1960 +6032,26 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get utilityFunctions => 'Utility Functions'; -} - -/// The translations for Chinese, as used in Taiwan (`zh_TW`). -class AppLocalizationsZhTw extends AppLocalizationsZh { - AppLocalizationsZhTw() : super('zh_TW'); - - @override - String get appName => 'SpotiFLAC'; - - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - - @override - String get navHome => 'Home'; - - @override - String get navHistory => 'History'; - - @override - String get navSettings => 'Settings'; - - @override - String get navStore => 'Store'; - - @override - String get homeTitle => 'Home'; - - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - - @override - String get homeSubtitle => 'Paste a Spotify link or search by name'; - - @override - String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; - - @override - String get homeRecent => 'Recent'; - - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - - @override - String get historyFilterAll => 'All'; - - @override - String get historyFilterAlbums => 'Albums'; - - @override - String get historyFilterSingles => 'Singles'; - - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - - @override - String get settingsTitle => 'Settings'; - - @override - String get settingsDownload => 'Download'; - - @override - String get settingsAppearance => 'Appearance'; - - @override - String get settingsOptions => 'Options'; - - @override - String get settingsExtensions => 'Extensions'; - - @override - String get settingsAbout => 'About'; - - @override - String get downloadTitle => 'Download'; - - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - - @override - String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; - - @override - String get downloadFilenameFormat => 'Filename Format'; - - @override - String get downloadFolderOrganization => 'Folder Organization'; - - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - - @override - String get appearanceTitle => 'Appearance'; - - @override - String get appearanceTheme => 'Theme'; - - @override - String get appearanceThemeSystem => 'System'; - - @override - String get appearanceThemeLight => 'Light'; - - @override - String get appearanceThemeDark => 'Dark'; - - @override - String get appearanceDynamicColor => 'Dynamic Color'; - - @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - - @override - String get appearanceAccentColor => 'Accent Color'; - - @override - String get appearanceHistoryView => 'History View'; - - @override - String get appearanceHistoryViewList => 'List'; - - @override - String get appearanceHistoryViewGrid => 'Grid'; - - @override - String get optionsTitle => 'Options'; - - @override - String get optionsSearchSource => 'Search Source'; - - @override - String get optionsPrimaryProvider => 'Primary Provider'; - - @override - String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; - - @override - String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; - } - - @override - String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; - - @override - String get optionsAutoFallback => 'Auto Fallback'; - - @override - String get optionsAutoFallbackSubtitle => - 'Try other services if download fails'; - - @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; - - @override - String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; - - @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; - - @override - String get optionsEmbedLyrics => 'Embed Lyrics'; - - @override - String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; - - @override - String get optionsMaxQualityCover => 'Max Quality Cover'; - - @override - String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; - - @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; - - @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; - - @override - String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; - } - - @override - String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; - - @override - String get optionsExtensionStore => 'Extension Store'; - - @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; - - @override - String get optionsCheckUpdates => 'Check for Updates'; - - @override - String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; - - @override - String get optionsUpdateChannel => 'Update Channel'; - - @override - String get optionsUpdateChannelStable => 'Stable releases only'; - - @override - String get optionsUpdateChannelPreview => 'Get preview releases'; - - @override - String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; - - @override - String get optionsClearHistory => 'Clear Download History'; - - @override - String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; - - @override - String get optionsDetailedLogging => 'Detailed Logging'; - - @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; - - @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; - - @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; - - @override - String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; - } - - @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; - - @override - String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; - - @override - String get extensionsTitle => 'Extensions'; - - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - - @override - String get extensionsDisabled => 'Disabled'; - - @override - String extensionsVersion(String version) { - return 'Version $version'; - } - - @override - String extensionsAuthor(String author) { - return 'by $author'; - } - - @override - String get extensionsUninstall => 'Uninstall'; - - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - - @override - String get storeTitle => 'Extension Store'; - - @override - String get storeSearch => 'Search extensions...'; - - @override - String get storeInstall => 'Install'; - - @override - String get storeInstalled => 'Installed'; - - @override - String get storeUpdate => 'Update'; - - @override - String get aboutTitle => 'About'; - - @override - String get aboutContributors => 'Contributors'; - - @override - String get aboutMobileDeveloper => 'Mobile version developer'; - - @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; - - @override - String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; - - @override - String get aboutSpecialThanks => 'Special Thanks'; - - @override - String get aboutLinks => 'Links'; - - @override - String get aboutMobileSource => 'Mobile source code'; - - @override - String get aboutPCSource => 'PC source code'; - - @override - String get aboutReportIssue => 'Report an issue'; - - @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; - - @override - String get aboutFeatureRequest => 'Feature request'; - - @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; - - @override - String get aboutSupport => 'Support'; - - @override - String get aboutBuyMeCoffee => 'Buy me a coffee'; - - @override - String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; - - @override - String get aboutApp => 'App'; - - @override - String get aboutVersion => 'Version'; - - @override - String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; - - @override - String get aboutSachinsenalDesc => - 'The original HiFi project creator. The foundation of Tidal integration!'; - - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - - @override - String get aboutDabMusic => 'DAB Music'; - - @override - String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; - - @override - String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - - @override - String get artistAlbums => 'Albums'; - - @override - String get artistSingles => 'Singles & EPs'; - - @override - String get artistCompilations => 'Compilations'; - - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - - @override - String get trackMetadataService => 'Service'; - - @override - String get trackMetadataPlay => 'Play'; - - @override - String get trackMetadataShare => 'Share'; - - @override - String get trackMetadataDelete => 'Delete'; - - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - - @override - String get setupGrantPermission => 'Grant Permission'; - - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - - @override - String get setupSkip => 'Skip for now'; - - @override - String get setupStorageAccessRequired => 'Storage Access Required'; - - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - - @override - String get setupStorageAccessMessageAndroid11 => - 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; - - @override - String get setupOpenSettings => 'Open Settings'; - - @override - String get setupPermissionDeniedMessage => - 'Permission denied. Please grant all permissions to continue.'; - - @override - String setupPermissionRequired(String permissionType) { - return '$permissionType Permission Required'; - } - - @override - String setupPermissionRequiredMessage(String permissionType) { - return '$permissionType permission is required for the best experience. You can change this later in Settings.'; - } - - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - - @override - String get setupUseDefaultFolder => 'Use Default Folder?'; - - @override - String get setupNoFolderSelected => - 'No folder selected. Would you like to use the default Music folder?'; - - @override - String get setupUseDefault => 'Use Default'; - - @override - String get setupDownloadLocationTitle => 'Download Location'; - - @override - String get setupDownloadLocationIosMessage => - 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; - - @override - String get setupAppDocumentsFolder => 'App Documents Folder'; - - @override - String get setupAppDocumentsFolderSubtitle => - 'Recommended - accessible via Files app'; - - @override - String get setupChooseFromFiles => 'Choose from Files'; - - @override - String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; - - @override - String get setupIosEmptyFolderWarning => - 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; - - @override - String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - - @override - String get setupStorageGranted => 'Storage Permission Granted!'; - - @override - String get setupStorageRequired => 'Storage Permission Required'; - - @override - String get setupStorageDescription => - 'SpotiFLAC needs storage permission to save your downloaded music files.'; - - @override - String get setupNotificationGranted => 'Notification Permission Granted!'; - - @override - String get setupNotificationEnable => 'Enable Notifications'; - - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - - @override - String get setupFolderChoose => 'Choose Download Folder'; - - @override - String get setupFolderDescription => - 'Select a folder where your downloaded music will be saved.'; - - @override - String get setupChangeFolder => 'Change Folder'; - - @override - String get setupSelectFolder => 'Select Folder'; - - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - - @override - String get setupEnableNotifications => 'Enable Notifications'; - - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - - @override - String get setupNotificationBackgroundDescription => - 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; - - @override - String get setupSkipForNow => 'Skip for now'; - - @override - String get setupBack => 'Back'; - - @override - String get setupNext => 'Next'; - - @override - String get setupGetStarted => 'Get Started'; - - @override - String get setupSkipAndStart => 'Skip & Start'; - - @override - String get setupAllowAccessToManageFiles => - 'Please enable \"Allow access to manage all files\" in the next screen.'; - - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - - @override - String get dialogCancel => 'Cancel'; - - @override - String get dialogOk => 'OK'; - - @override - String get dialogSave => 'Save'; - - @override - String get dialogDelete => 'Delete'; - - @override - String get dialogRetry => 'Retry'; - - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - - @override - String get dialogClear => 'Clear'; - - @override - String get dialogConfirm => 'Confirm'; - - @override - String get dialogDone => 'Done'; - - @override - String get dialogImport => 'Import'; - - @override - String get dialogDiscard => 'Discard'; - - @override - String get dialogRemove => 'Remove'; - - @override - String get dialogUninstall => 'Uninstall'; - - @override - String get dialogDiscardChanges => 'Discard Changes?'; - - @override - String get dialogUnsavedChanges => - 'You have unsaved changes. Do you want to discard them?'; - - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - - @override - String get dialogClearAll => 'Clear All'; - - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - - @override - String get dialogRemoveExtension => 'Remove Extension'; - - @override - String get dialogRemoveExtensionMessage => - 'Are you sure you want to remove this extension? This cannot be undone.'; - - @override - String get dialogUninstallExtension => 'Uninstall Extension?'; - - @override - String dialogUninstallExtensionMessage(String extensionName) { - return 'Are you sure you want to remove $extensionName?'; - } - - @override - String get dialogClearHistoryTitle => 'Clear History'; - - @override - String get dialogClearHistoryMessage => - 'Are you sure you want to clear all download history? This cannot be undone.'; - - @override - String get dialogDeleteSelectedTitle => 'Delete Selected'; - - @override - String dialogDeleteSelectedMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; - } - - @override - String get dialogImportPlaylistTitle => 'Import Playlist'; - - @override - String dialogImportPlaylistMessage(int count) { - return 'Found $count tracks in CSV. Add them to download queue?'; - } - - @override - String snackbarAddedToQueue(String trackName) { - return 'Added \"$trackName\" to queue'; - } - - @override - String snackbarAddedTracksToQueue(int count) { - return 'Added $count tracks to queue'; - } - - @override - String snackbarAlreadyDownloaded(String trackName) { - return '\"$trackName\" already downloaded'; - } - - @override - String get snackbarHistoryCleared => 'History cleared'; - - @override - String get snackbarCredentialsSaved => 'Credentials saved'; - - @override - String get snackbarCredentialsCleared => 'Credentials cleared'; - - @override - String snackbarDeletedTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Deleted $count $_temp0'; - } - - @override - String snackbarCannotOpenFile(String error) { - return 'Cannot open file: $error'; - } - - @override - String get snackbarFillAllFields => 'Please fill all fields'; - - @override - String get snackbarViewQueue => 'View Queue'; - - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - - @override - String snackbarUrlCopied(String platform) { - return '$platform URL copied to clipboard'; - } - - @override - String get snackbarFileNotFound => 'File not found'; - - @override - String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; - - @override - String get snackbarProviderPrioritySaved => 'Provider priority saved'; - - @override - String get snackbarMetadataProviderSaved => - 'Metadata provider priority saved'; - - @override - String snackbarExtensionInstalled(String extensionName) { - return '$extensionName installed.'; - } - - @override - String snackbarExtensionUpdated(String extensionName) { - return '$extensionName updated.'; - } - - @override - String get snackbarFailedToInstall => 'Failed to install extension'; - - @override - String get snackbarFailedToUpdate => 'Failed to update extension'; - - @override - String get errorRateLimited => 'Rate Limited'; - - @override - String get errorRateLimitedMessage => - 'Too many requests. Please wait a moment before searching again.'; - - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - - @override - String get errorNoTracksFound => 'No tracks found'; - - @override - String errorMissingExtensionSource(String item) { - return 'Cannot load $item: missing extension source'; - } - - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - - @override - String get actionPause => 'Pause'; - - @override - String get actionResume => 'Resume'; - - @override - String get actionCancel => 'Cancel'; - - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - - @override - String get actionSelectAll => 'Select All'; - - @override - String get actionDeselect => 'Deselect'; - - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - - @override - String get actionRemoveCredentials => 'Remove Credentials'; - - @override - String get actionSaveCredentials => 'Save Credentials'; - - @override - String selectionSelected(int count) { - return '$count selected'; - } - - @override - String get selectionAllSelected => 'All tracks selected'; - - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - - @override - String get selectionSelectToDelete => 'Select tracks to delete'; - - @override - String progressFetchingMetadata(int current, int total) { - return 'Fetching metadata... $current/$total'; - } - - @override - String get progressReadingCsv => 'Reading CSV...'; - - @override - String get searchSongs => 'Songs'; - - @override - String get searchArtists => 'Artists'; - - @override - String get searchAlbums => 'Albums'; - - @override - String get searchPlaylists => 'Playlists'; - - @override - String get tooltipPlay => 'Play'; - - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - - @override - String get filenameFormat => 'Filename Format'; - - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Folder Organization'; - - @override - String get folderOrganizationNone => 'No organization'; - - @override - String get folderOrganizationByArtist => 'By Artist'; - - @override - String get folderOrganizationByAlbum => 'By Album'; - - @override - String get folderOrganizationByArtistAlbum => 'Artist/Album'; - - @override - String get folderOrganizationDescription => - 'Organize downloaded files into folders'; - - @override - String get folderOrganizationNoneSubtitle => 'All files in download folder'; - - @override - String get folderOrganizationByArtistSubtitle => - 'Separate folder for each artist'; - - @override - String get folderOrganizationByAlbumSubtitle => - 'Separate folder for each album'; - - @override - String get folderOrganizationByArtistAlbumSubtitle => - 'Nested folders for artist and album'; - - @override - String get updateAvailable => 'Update Available'; - - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - - @override - String get updateLater => 'Later'; - - @override - String get updateChangelog => 'Changelog'; - - @override - String get updateStartingDownload => 'Starting download...'; - - @override - String get updateDownloadFailed => 'Download failed'; - - @override - String get updateFailedMessage => 'Failed to download update'; - - @override - String get updateNewVersionReady => 'A new version is ready'; - - @override - String get updateCurrent => 'Current'; - - @override - String get updateNew => 'New'; - - @override - String get updateDownloading => 'Downloading...'; - - @override - String get updateWhatsNew => 'What\'s New'; - - @override - String get updateDownloadInstall => 'Download & Install'; - - @override - String get updateDontRemind => 'Don\'t remind'; - - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - - @override - String get providerPriorityTitle => 'Provider Priority'; - - @override - String get providerPriorityDescription => - 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; - - @override - String get providerPriorityInfo => - 'If a track is not available on the first provider, the app will automatically try the next one.'; - - @override - String get providerBuiltIn => 'Built-in'; - - @override - String get providerExtension => 'Extension'; - - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - - @override - String get metadataProviderPriorityTitle => 'Metadata Priority'; - - @override - String get metadataProviderPriorityDescription => - 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; - - @override - String get metadataProviderPriorityInfo => - 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; - - @override - String get metadataNoRateLimits => 'No rate limits'; - - @override - String get metadataMayRateLimit => 'May rate limit'; - - @override - String get logTitle => 'Logs'; - - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - - @override - String get logCopied => 'Logs copied to clipboard'; - - @override - String get logSearchHint => 'Search logs...'; - - @override - String get logFilterLevel => 'Level'; - - @override - String get logFilterSection => 'Filter'; - - @override - String get logShareLogs => 'Share logs'; - - @override - String get logClearLogs => 'Clear logs'; - - @override - String get logClearLogsTitle => 'Clear Logs'; - - @override - String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - - @override - String get logFilterBySeverity => 'Filter logs by severity'; - - @override - String get logNoLogsYet => 'No logs yet'; - - @override - String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - - @override - String logEntriesFiltered(int count) { - return 'Entries ($count filtered)'; - } - - @override - String logEntries(int count) { - return 'Entries ($count)'; - } - - @override - String get credentialsTitle => 'Spotify Credentials'; - - @override - String get credentialsDescription => - 'Enter your Client ID and Secret to use your own Spotify application quota.'; - - @override - String get credentialsClientId => 'Client ID'; - - @override - String get credentialsClientIdHint => 'Paste Client ID'; - - @override - String get credentialsClientSecret => 'Client Secret'; - - @override - String get credentialsClientSecretHint => 'Paste Client Secret'; - - @override - String get channelStable => 'Stable'; - - @override - String get channelPreview => 'Preview'; - - @override - String get sectionSearchSource => 'Search Source'; - - @override - String get sectionDownload => 'Download'; - - @override - String get sectionPerformance => 'Performance'; - - @override - String get sectionApp => 'App'; - - @override - String get sectionData => 'Data'; - - @override - String get sectionDebug => 'Debug'; - - @override - String get sectionService => 'Service'; - - @override - String get sectionAudioQuality => 'Audio Quality'; - - @override - String get sectionFileSettings => 'File Settings'; - - @override - String get sectionColor => 'Color'; - - @override - String get sectionTheme => 'Theme'; - - @override - String get sectionLayout => 'Layout'; - - @override - String get settingsAppearanceSubtitle => 'Theme, colors, display'; - - @override - String get settingsDownloadSubtitle => 'Service, quality, filename format'; - - @override - String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; - - @override - String get settingsExtensionsSubtitle => 'Manage download providers'; - - @override - String get settingsLogsSubtitle => 'View app logs for debugging'; - - @override - String get loadingSharedLink => 'Loading shared link...'; - - @override - String get pressBackAgainToExit => 'Press back again to exit'; - - @override - String get tracksHeader => 'Tracks'; - - @override - String downloadAllCount(int count) { - return 'Download All ($count)'; - } - - @override - String tracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get trackCopyFilePath => 'Copy file path'; - - @override - String get trackRemoveFromDevice => 'Remove from device'; - - @override - String get trackLoadLyrics => 'Load Lyrics'; - - @override - String get trackMetadata => 'Metadata'; - - @override - String get trackFileInfo => 'File Info'; - - @override - String get trackLyrics => 'Lyrics'; - - @override - String get trackFileNotFound => 'File not found'; - - @override - String get trackOpenInDeezer => 'Open in Deezer'; - - @override - String get trackOpenInSpotify => 'Open in Spotify'; - - @override - String get trackTrackName => 'Track name'; - - @override - String get trackArtist => 'Artist'; - - @override - String get trackAlbumArtist => 'Album artist'; - - @override - String get trackAlbum => 'Album'; - - @override - String get trackTrackNumber => 'Track number'; - - @override - String get trackDiscNumber => 'Disc number'; - - @override - String get trackDuration => 'Duration'; - - @override - String get trackAudioQuality => 'Audio quality'; - - @override - String get trackReleaseDate => 'Release date'; - - @override - String get trackDownloaded => 'Downloaded'; - - @override - String get trackCopyLyrics => 'Copy lyrics'; - - @override - String get trackLyricsNotAvailable => 'Lyrics not available for this track'; - - @override - String get trackLyricsTimeout => 'Request timed out. Try again later.'; - - @override - String get trackLyricsLoadFailed => 'Failed to load lyrics'; - - @override - String get trackCopiedToClipboard => 'Copied to clipboard'; - - @override - String get trackDeleteConfirmTitle => 'Remove from device?'; - - @override - String get trackDeleteConfirmMessage => - 'This will permanently delete the downloaded file and remove it from your history.'; - - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - - @override - String get dateToday => 'Today'; - - @override - String get dateYesterday => 'Yesterday'; - - @override - String dateDaysAgo(int count) { - return '$count days ago'; - } - - @override - String dateWeeksAgo(int count) { - return '$count weeks ago'; - } - - @override - String dateMonthsAgo(int count) { - return '$count months ago'; - } - - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - - @override - String get storeFilterAll => 'All'; - - @override - String get storeFilterMetadata => 'Metadata'; - - @override - String get storeFilterDownload => 'Download'; - - @override - String get storeFilterUtility => 'Utility'; - - @override - String get storeFilterLyrics => 'Lyrics'; - - @override - String get storeFilterIntegration => 'Integration'; - - @override - String get storeClearFilters => 'Clear filters'; - - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - - @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; - - @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; - - @override - String get extensionAuthor => 'Author'; - - @override - String get extensionId => 'ID'; - - @override - String get extensionError => 'Error'; - - @override - String get extensionCapabilities => 'Capabilities'; - - @override - String get extensionMetadataProvider => 'Metadata Provider'; - - @override - String get extensionDownloadProvider => 'Download Provider'; - - @override - String get extensionLyricsProvider => 'Lyrics Provider'; - - @override - String get extensionUrlHandler => 'URL Handler'; - - @override - String get extensionQualityOptions => 'Quality Options'; - - @override - String get extensionPostProcessingHooks => 'Post-Processing Hooks'; - - @override - String get extensionPermissions => 'Permissions'; - - @override - String get extensionSettings => 'Settings'; - - @override - String get extensionRemoveButton => 'Remove Extension'; - - @override - String get extensionUpdated => 'Updated'; - - @override - String get extensionMinAppVersion => 'Min App Version'; - - @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; - - @override - String get extensionPostProcessing => 'Post-Processing'; - - @override - String extensionHooksAvailable(int count) { - return '$count hook(s) available'; - } - - @override - String extensionPatternsCount(int count) { - return '$count pattern(s)'; - } - - @override - String extensionStrategy(String strategy) { - return 'Strategy: $strategy'; - } - - @override - String get extensionsProviderPrioritySection => 'Provider Priority'; - - @override - String get extensionsInstalledSection => 'Installed Extensions'; - - @override - String get extensionsNoExtensions => 'No extensions installed'; - - @override - String get extensionsNoExtensionsSubtitle => - 'Install .spotiflac-ext files to add new providers'; - - @override - String get extensionsInstallButton => 'Install Extension'; - - @override - String get extensionsInfoTip => - 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; - - @override - String get extensionsInstalledSuccess => 'Extension installed successfully'; - - @override - String get extensionsDownloadPriority => 'Download Priority'; - - @override - String get extensionsDownloadPrioritySubtitle => 'Set download service order'; - - @override - String get extensionsNoDownloadProvider => - 'No extensions with download provider'; - - @override - String get extensionsMetadataPriority => 'Metadata Priority'; - - @override - String get extensionsMetadataPrioritySubtitle => - 'Set search & metadata source order'; - - @override - String get extensionsNoMetadataProvider => - 'No extensions with metadata provider'; - - @override - String get extensionsSearchProvider => 'Search Provider'; - - @override - String get extensionsNoCustomSearch => 'No extensions with custom search'; - - @override - String get extensionsSearchProviderDescription => - 'Choose which service to use for searching tracks'; - - @override - String get extensionsCustomSearch => 'Custom search'; - - @override - String get extensionsErrorLoading => 'Error loading extension'; - - @override - String get qualityFlacLossless => 'FLAC Lossless'; - - @override - String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; - - @override - String get qualityHiResFlac => 'Hi-Res FLAC'; - - @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; - - @override - String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; - - @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - - @override - String get qualityNote => - 'Actual quality depends on track availability from the service'; - - @override - String get downloadAskBeforeDownload => 'Ask Before Download'; - - @override - String get downloadDirectory => 'Download Directory'; - - @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; - - @override - String get downloadAlbumFolderStructure => 'Album Folder Structure'; - - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - - @override - String get downloadSelectQuality => 'Select Quality'; - - @override - String get downloadFrom => 'Download From'; - - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - - @override - String get appearanceAmoledDark => 'AMOLED Dark'; - - @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; - - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - - @override - String get queueClearAll => 'Clear All'; - - @override - String get queueClearAllMessage => - 'Are you sure you want to clear all downloads?'; - - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - - @override - String get albumFolderArtistAlbum => 'Artist / Album'; - - @override - String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; - - @override - String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; - - @override - String get albumFolderArtistYearAlbumSubtitle => - 'Albums/Artist Name/[2005] Album Name/'; - - @override - String get albumFolderAlbumOnly => 'Album Only'; - - @override - String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; - - @override - String get albumFolderYearAlbum => '[Year] Album'; - - @override - String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; - - @override - String get downloadedAlbumDeleteSelected => 'Delete Selected'; - - @override - String downloadedAlbumDeleteMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; - } - - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - - @override - String downloadedAlbumSelectedCount(int count) { - return '$count selected'; - } - - @override - String get downloadedAlbumAllSelected => 'All tracks selected'; - - @override - String get downloadedAlbumTapToSelect => 'Tap tracks to select'; - - @override - String downloadedAlbumDeleteCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - - @override - String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; - - @override - String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index c5d32df4..28890472 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -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" + } + } } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 98366459..fce509d7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -290,6 +290,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", @@ -617,6 +619,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 +1058,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 +1160,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", @@ -1320,6 +1355,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"}, @@ -1459,6 +1504,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"}, diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb new file mode 100644 index 00000000..b451b4a5 --- /dev/null +++ b/lib/l10n/arb/app_es_ES.arb @@ -0,0 +1,2615 @@ +{ + "@@locale": "es_ES", + "@@last_modified": "2026-01-16", + "appName": "SpotiFLAC", + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, + "appDescription": "Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", + "@appDescription": { + "description": "App description shown in about page" + }, + "navHome": "Inicio", + "@navHome": { + "description": "Bottom navigation - Home tab" + }, + "navHistory": "Historial", + "@navHistory": { + "description": "Bottom navigation - History tab" + }, + "navSettings": "Ajustes", + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, + "navStore": "Tienda", + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, + "homeTitle": "Inicio", + "@homeTitle": { + "description": "Home screen title" + }, + "homeSearchHint": "Pegar URL Spotify o buscar...", + "@homeSearchHint": { + "description": "Placeholder text in search box" + }, + "homeSearchHintExtension": "Buscar con {extensionName}...", + "@homeSearchHintExtension": { + "description": "Placeholder when extension search is active", + "placeholders": { + "extensionName": { + "type": "String", + "description": "Name of the active extension" + } + } + }, + "homeSubtitle": "Pegar enlace de Spotify o buscar por nombre", + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, + "homeSupports": "Soportes: Pista, Álbum, Lista de reproducción, URLs de Artistas", + "@homeSupports": { + "description": "Info text about supported URL types" + }, + "homeRecent": "Recientes", + "@homeRecent": { + "description": "Section header for recent searches" + }, + "historyTitle": "Historial", + "@historyTitle": { + "description": "History screen title" + }, + "historyDownloading": "Descargando ({count})", + "@historyDownloading": { + "description": "Tab showing active downloads count", + "placeholders": { + "count": { + "type": "int", + "description": "Number of active downloads" + } + } + }, + "historyDownloaded": "Descargado", + "@historyDownloaded": { + "description": "Tab showing completed downloads" + }, + "historyFilterAll": "Todo", + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, + "historyFilterAlbums": "Álbumes", + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, + "historyFilterSingles": "Pistas", + "@historyFilterSingles": { + "description": "Filter chip - show singles only" + }, + "historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", + "@historyTracksCount": { + "description": "Track count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}", + "@historyAlbumsCount": { + "description": "Album count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyNoDownloads": "No hay historial de descargas", + "@historyNoDownloads": { + "description": "Empty state title" + }, + "historyNoDownloadsSubtitle": "Las pistas descargadas aparecerán aquí", + "@historyNoDownloadsSubtitle": { + "description": "Empty state subtitle" + }, + "historyNoAlbums": "No hay descargas de álbum", + "@historyNoAlbums": { + "description": "Empty state when filtering albums" + }, + "historyNoAlbumsSubtitle": "Descargar múltiples pistas de un álbum para verlas aquí", + "@historyNoAlbumsSubtitle": { + "description": "Empty state subtitle for albums filter" + }, + "historyNoSingles": "No hay descargas", + "@historyNoSingles": { + "description": "Empty state when filtering singles" + }, + "historyNoSinglesSubtitle": "Las descargas de una sola pista aparecerán aquí", + "@historyNoSinglesSubtitle": { + "description": "Empty state subtitle for singles filter" + }, + "settingsTitle": "Ajustes", + "@settingsTitle": { + "description": "Settings screen title" + }, + "settingsDownload": "Descargar", + "@settingsDownload": { + "description": "Settings section - download options" + }, + "settingsAppearance": "Apariencia", + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, + "settingsOptions": "Opciones", + "@settingsOptions": { + "description": "Settings section - app options" + }, + "settingsExtensions": "Extensiones", + "@settingsExtensions": { + "description": "Settings section - extension management" + }, + "settingsAbout": "Acerca de", + "@settingsAbout": { + "description": "Settings section - app info" + }, + "downloadTitle": "Descargar", + "@downloadTitle": { + "description": "Download settings page title" + }, + "downloadLocation": "Ubicación de descarga", + "@downloadLocation": { + "description": "Setting for download folder" + }, + "downloadLocationSubtitle": "Elija dónde guardar los archivos", + "@downloadLocationSubtitle": { + "description": "Subtitle for download location" + }, + "downloadLocationDefault": "Ubicación predeterminada", + "@downloadLocationDefault": { + "description": "Shown when using default folder" + }, + "downloadDefaultService": "Servicio por defecto", + "@downloadDefaultService": { + "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" + }, + "downloadDefaultServiceSubtitle": "Servicio usado para descargas", + "@downloadDefaultServiceSubtitle": { + "description": "Subtitle for default service" + }, + "downloadDefaultQuality": "Calidad por defecto", + "@downloadDefaultQuality": { + "description": "Setting for audio quality" + }, + "downloadAskQuality": "Preguntar calidad antes de descargar", + "@downloadAskQuality": { + "description": "Toggle to show quality picker" + }, + "downloadAskQualitySubtitle": "Mostrar selector de calidad para cada descarga", + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, + "downloadFilenameFormat": "Formato del nombre del archivo", + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, + "downloadFolderOrganization": "Organización de carpetas", + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, + "downloadSeparateSingles": "Separar Pistas", + "@downloadSeparateSingles": { + "description": "Toggle to separate single tracks" + }, + "downloadSeparateSinglesSubtitle": "Colocar pistas individuales en una carpeta separada", + "@downloadSeparateSinglesSubtitle": { + "description": "Subtitle for separate singles toggle" + }, + "qualityBest": "Mejor disponible", + "@qualityBest": { + "description": "Audio quality option - highest available" + }, + "qualityFlac": "FLAC", + "@qualityFlac": { + "description": "Audio quality option - FLAC lossless" + }, + "quality320": "320 kbps", + "@quality320": { + "description": "Audio quality option - 320kbps MP3" + }, + "quality128": "128 kbps", + "@quality128": { + "description": "Audio quality option - 128kbps MP3" + }, + "appearanceTitle": "Apariencia", + "@appearanceTitle": { + "description": "Appearance settings page title" + }, + "appearanceTheme": "Tema", + "@appearanceTheme": { + "description": "Theme mode setting" + }, + "appearanceThemeSystem": "Sistema", + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, + "appearanceThemeLight": "Claro", + "@appearanceThemeLight": { + "description": "Light theme" + }, + "appearanceThemeDark": "Oscuro", + "@appearanceThemeDark": { + "description": "Dark theme" + }, + "appearanceDynamicColor": "Color dinámico", + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, + "appearanceDynamicColorSubtitle": "Usar colores de tu fondo de pantalla", + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, + "appearanceAccentColor": "Color Secundario", + "@appearanceAccentColor": { + "description": "Custom accent color picker" + }, + "appearanceHistoryView": "Vista de Historial", + "@appearanceHistoryView": { + "description": "Layout style for history" + }, + "appearanceHistoryViewList": "Lista", + "@appearanceHistoryViewList": { + "description": "List layout option" + }, + "appearanceHistoryViewGrid": "Cuadrícula", + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, + "optionsTitle": "Opciones", + "@optionsTitle": { + "description": "Options settings page title" + }, + "optionsSearchSource": "Buscar Fuente", + "@optionsSearchSource": { + "description": "Section for search provider settings" + }, + "optionsPrimaryProvider": "Proveedor Principal", + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, + "optionsPrimaryProviderSubtitle": "Servicio usado al buscar por nombre de la pista.", + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, + "optionsUsingExtension": "Usando la extensión: {extensionName}", + "@optionsUsingExtension": { + "description": "Shows active extension name", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "optionsSwitchBack": "Toque Deezer o Spotify para volver desde la extensión", + "@optionsSwitchBack": { + "description": "Hint to switch back to built-in providers" + }, + "optionsAutoFallback": "Alternativa automática", + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, + "optionsAutoFallbackSubtitle": "Pruebe otros servicios si falla la descarga", + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, + "optionsUseExtensionProviders": "Usar proveedores de extensiones", + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, + "optionsUseExtensionProvidersOn": "Las extensiones serán probadas primero", + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, + "optionsUseExtensionProvidersOff": "Utilizando sólo proveedores integrados", + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, + "optionsEmbedLyrics": "Incrustar Letras", + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, + "optionsEmbedLyricsSubtitle": "Insertar letras sincronizadas en archivos FLAC", + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, + "optionsMaxQualityCover": "Carátula de calidad máxima", + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, + "optionsMaxQualityCoverSubtitle": "Descargar carátula de resolución máxima", + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, + "optionsConcurrentDownloads": "Descargas Simultáneas", + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, + "optionsConcurrentSequential": "Secuencial (1 a la vez)", + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, + "optionsConcurrentParallel": "{count} descargas paralelas", + "@optionsConcurrentParallel": { + "description": "Multiple parallel downloads", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "optionsConcurrentWarning": "Las descargas paralelas pueden activar la limitación de velocidad", + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, + "optionsExtensionStore": "Tienda de extensiones", + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, + "optionsExtensionStoreSubtitle": "Mostrar pestaña de tienda en la navegación", + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, + "optionsCheckUpdates": "Comprobar actualizaciones", + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, + "optionsCheckUpdatesSubtitle": "Notificar cuando una nueva versión esté disponible", + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, + "optionsUpdateChannel": "Tipo de actualizaciones", + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, + "optionsUpdateChannelStable": "Sólo versiones estables", + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, + "optionsUpdateChannelPreview": "Versión preliminar", + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, + "optionsUpdateChannelWarning": "La Versión preliminar puede contener errores o características incompletas", + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, + "optionsClearHistory": "Borrar el historial de descargas", + "@optionsClearHistory": { + "description": "Delete all download history" + }, + "optionsClearHistorySubtitle": "Eliminar todas las pistas descargadas del historial", + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, + "optionsDetailedLogging": "Registro detallado", + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, + "optionsDetailedLoggingOn": "Registros detallados están siendo registrados", + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, + "optionsDetailedLoggingOff": "Habilitar para informes de errores", + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, + "optionsSpotifyCredentials": "Credenciales de Spotify", + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, + "optionsSpotifyCredentialsConfigured": "ID de cliente: {clientId}...", + "@optionsSpotifyCredentialsConfigured": { + "description": "Shows configured client ID preview", + "placeholders": { + "clientId": { + "type": "String" + } + } + }, + "optionsSpotifyCredentialsRequired": "Requerido - toque para configurar", + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, + "optionsSpotifyWarning": "Spotify requiere tus propias credenciales API. Obténgalas gratis de developer.spotify.com", + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, + "extensionsTitle": "Extensiones", + "@extensionsTitle": { + "description": "Extensions page title" + }, + "extensionsInstalled": "Extensiones instaladas", + "@extensionsInstalled": { + "description": "Section header for installed extensions" + }, + "extensionsNone": "No hay extensiones instaladas", + "@extensionsNone": { + "description": "Empty state title" + }, + "extensionsNoneSubtitle": "Instalar extensiones desde la pestaña Tienda", + "@extensionsNoneSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsEnabled": "Habilitado", + "@extensionsEnabled": { + "description": "Extension status - active" + }, + "extensionsDisabled": "Deshabilitado", + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, + "extensionsVersion": "Versión {version}", + "@extensionsVersion": { + "description": "Extension version display", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "extensionsAuthor": "por {author}", + "@extensionsAuthor": { + "description": "Extension author credit", + "placeholders": { + "author": { + "type": "String" + } + } + }, + "extensionsUninstall": "Desinstalar", + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, + "extensionsSetAsSearch": "Establecer como proveedor de búsqueda", + "@extensionsSetAsSearch": { + "description": "Use extension for search" + }, + "storeTitle": "Tienda de extensiones", + "@storeTitle": { + "description": "Store screen title" + }, + "storeSearch": "Buscar extensiones...", + "@storeSearch": { + "description": "Store search placeholder" + }, + "storeInstall": "Instalar", + "@storeInstall": { + "description": "Install extension button" + }, + "storeInstalled": "Instalada", + "@storeInstalled": { + "description": "Already installed badge" + }, + "storeUpdate": "Actualizar", + "@storeUpdate": { + "description": "Update available button" + }, + "aboutTitle": "Acerca de", + "@aboutTitle": { + "description": "About page title" + }, + "aboutContributors": "Colaboradores", + "@aboutContributors": { + "description": "Section for contributors" + }, + "aboutMobileDeveloper": "Desarrollador de versiones móviles", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, + "aboutOriginalCreator": "Creador original de SpotiFLAC", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, + "aboutLogoArtist": "¡El talentoso artista que creó nuestro hermoso logo!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutSpecialThanks": "Agradecimientos especiales", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Enlaces", + "@aboutLinks": { + "description": "Section for external links" + }, + "aboutMobileSource": "Código fuente móvil", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, + "aboutPCSource": "Código fuente de PC", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, + "aboutReportIssue": "Reportar un problema", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, + "aboutReportIssueSubtitle": "Reporta cualquier problema que encuentres", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, + "aboutFeatureRequest": "Sugerir una función", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, + "aboutFeatureRequestSubtitle": "Sugerir nuevas funciones para la aplicación", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutSupport": "Soporte", + "@aboutSupport": { + "description": "Section for support/donation links" + }, + "aboutBuyMeCoffee": "Invítame a un café", + "@aboutBuyMeCoffee": { + "description": "Donation link" + }, + "aboutBuyMeCoffeeSubtitle": "Apoyar el desarrollo en Ko-fi", + "@aboutBuyMeCoffeeSubtitle": { + "description": "Subtitle for donation" + }, + "aboutApp": "Aplicación", + "@aboutApp": { + "description": "Section for app info" + }, + "aboutVersion": "Versión", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "El creador de la API QQDL & Hi-Fi. ¡Sin esta API, las descargas de Tidal no existiría!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "El creador original del proyecto Hi-Fi. ¡La base de la integración de Tidal!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutDoubleDouble": "DoubleDouble", + "@aboutDoubleDouble": { + "description": "Name of Amazon API service - DO NOT TRANSLATE" + }, + "aboutDoubleDoubleDesc": "API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!", + "@aboutDoubleDoubleDesc": { + "description": "Credit for DoubleDouble API" + }, + "aboutDabMusic": "Música DAB", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "La mejor API de streaming de Qobuz. ¡Las descargas de Hi-Res no serían posibles sin esto!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, + "aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" + }, + "albumTitle": "Álbum", + "@albumTitle": { + "description": "Album screen title" + }, + "albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", + "@albumTracks": { + "description": "Album track count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "albumDownloadAll": "Descargar Todo", + "@albumDownloadAll": { + "description": "Button to download all tracks" + }, + "albumDownloadRemaining": "Descargas Restantes", + "@albumDownloadRemaining": { + "description": "Button to download remaining tracks" + }, + "playlistTitle": "Lista de reproducción", + "@playlistTitle": { + "description": "Playlist screen title" + }, + "artistTitle": "Artista", + "@artistTitle": { + "description": "Artist screen title" + }, + "artistAlbums": "Álbumes", + "@artistAlbums": { + "description": "Section header for artist albums" + }, + "artistSingles": "Pistas y EPs", + "@artistSingles": { + "description": "Section header for singles/EPs" + }, + "artistCompilations": "Compilaciones", + "@artistCompilations": { + "description": "Section header for compilations" + }, + "artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}", + "@artistReleases": { + "description": "Artist release count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "artistPopular": "Populares", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} oyentes mensuales", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, + "trackMetadataTitle": "Información de pista", + "@trackMetadataTitle": { + "description": "Track metadata screen title" + }, + "trackMetadataArtist": "Artista", + "@trackMetadataArtist": { + "description": "Metadata field - artist name" + }, + "trackMetadataAlbum": "Álbum", + "@trackMetadataAlbum": { + "description": "Metadata field - album name" + }, + "trackMetadataDuration": "Duración", + "@trackMetadataDuration": { + "description": "Metadata field - track length" + }, + "trackMetadataQuality": "Calidad", + "@trackMetadataQuality": { + "description": "Metadata field - audio quality" + }, + "trackMetadataPath": "Ruta del archivo", + "@trackMetadataPath": { + "description": "Metadata field - file location" + }, + "trackMetadataDownloadedAt": "Descargado", + "@trackMetadataDownloadedAt": { + "description": "Metadata field - download date" + }, + "trackMetadataService": "Servicio", + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, + "trackMetadataPlay": "Reproducir", + "@trackMetadataPlay": { + "description": "Action button - play track" + }, + "trackMetadataShare": "Compartir", + "@trackMetadataShare": { + "description": "Action button - share track" + }, + "trackMetadataDelete": "Eliminar", + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, + "trackMetadataRedownload": "Volver a descargar", + "@trackMetadataRedownload": { + "description": "Action button - download again" + }, + "trackMetadataOpenFolder": "Abrir carpeta", + "@trackMetadataOpenFolder": { + "description": "Action button - open containing folder" + }, + "setupTitle": "Bienvenido a SpotiFLAC", + "@setupTitle": { + "description": "Setup wizard title" + }, + "setupSubtitle": "Comencemos", + "@setupSubtitle": { + "description": "Setup wizard subtitle" + }, + "setupStoragePermission": "Permiso de almacenamiento", + "@setupStoragePermission": { + "description": "Storage permission step title" + }, + "setupStoragePermissionSubtitle": "Necesario para guardar los archivos descargados", + "@setupStoragePermissionSubtitle": { + "description": "Explanation for storage permission" + }, + "setupStoragePermissionGranted": "Permiso aprobado", + "@setupStoragePermissionGranted": { + "description": "Status when permission granted" + }, + "setupStoragePermissionDenied": "Permiso denegado", + "@setupStoragePermissionDenied": { + "description": "Status when permission denied" + }, + "setupGrantPermission": "Conceder permiso", + "@setupGrantPermission": { + "description": "Button to request permission" + }, + "setupDownloadLocation": "Ubicación de descarga", + "@setupDownloadLocation": { + "description": "Download folder step title" + }, + "setupChooseFolder": "Seleccionar Carpeta", + "@setupChooseFolder": { + "description": "Button to pick folder" + }, + "setupContinue": "Continuar", + "@setupContinue": { + "description": "Continue to next step button" + }, + "setupSkip": "Omitir por ahora", + "@setupSkip": { + "description": "Skip current step button" + }, + "setupStorageAccessRequired": "Acceso al almacenamiento requerido", + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, + "setupStorageAccessMessage": "SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.", + "@setupStorageAccessMessage": { + "description": "Explanation for storage access" + }, + "setupStorageAccessMessageAndroid11": "Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.", + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, + "setupOpenSettings": "Abrir ajustes", + "@setupOpenSettings": { + "description": "Button to open system settings" + }, + "setupPermissionDeniedMessage": "Permiso denegado. Por favor, conceda todos los permisos para continuar.", + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, + "setupPermissionRequired": "Permiso de {permissionType} requerido", + "@setupPermissionRequired": { + "description": "Generic permission required title", + "placeholders": { + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } + } + }, + "setupPermissionRequiredMessage": "Se requiere un permiso {permissionType} para la mejor experiencia. Puedes cambiar esto más tarde en ajustes.", + "@setupPermissionRequiredMessage": { + "description": "Generic permission required message", + "placeholders": { + "permissionType": { + "type": "String" + } + } + }, + "setupSelectDownloadFolder": "Seleccionar carpeta de descarga", + "@setupSelectDownloadFolder": { + "description": "Folder selection step title" + }, + "setupUseDefaultFolder": "¿Usar carpeta por defecto?", + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, + "setupNoFolderSelected": "No se ha seleccionado ninguna carpeta. ¿Desea utilizar la carpeta por defecto?", + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, + "setupUseDefault": "Usar por defecto", + "@setupUseDefault": { + "description": "Button to use default folder" + }, + "setupDownloadLocationTitle": "Ubicación de descarga", + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, + "setupDownloadLocationIosMessage": "En iOS, las descargas se guardan en la carpeta de documentos de la aplicación. Puede acceder a ellas desde la aplicación Archivos.", + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, + "setupAppDocumentsFolder": "Carpeta de documentos de App", + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, + "setupAppDocumentsFolderSubtitle": "Recomendado - accesible desde la aplicación Archivos", + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, + "setupChooseFromFiles": "Elegir de archivos", + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, + "setupChooseFromFilesSubtitle": "Seleccione iCloud u otra ubicación", + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "Limitación de iOS: No se pueden seleccionar carpetas vacías. Elige una carpeta con al menos un archivo.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, + "setupDownloadInFlac": "Descargar pistas de Spotify en FLAC", + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, + "setupStepStorage": "Almacenamiento", + "@setupStepStorage": { + "description": "Setup step indicator - storage" + }, + "setupStepNotification": "Notificación", + "@setupStepNotification": { + "description": "Setup step indicator - notification" + }, + "setupStepFolder": "Carpeta", + "@setupStepFolder": { + "description": "Setup step indicator - folder" + }, + "setupStepSpotify": "Spotify", + "@setupStepSpotify": { + "description": "Setup step indicator - Spotify API" + }, + "setupStepPermission": "Permiso", + "@setupStepPermission": { + "description": "Setup step indicator - permission" + }, + "setupStorageGranted": "¡Permiso de almacenamiento concedido!", + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, + "setupStorageRequired": "Permiso de almacenamiento requerido", + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, + "setupStorageDescription": "SpotiFLAC necesita permiso de almacenamiento para guardar sus archivos de música descargados.", + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, + "setupNotificationGranted": "¡Acceso a las notificaciones permitido!", + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, + "setupNotificationEnable": "Activar notificaciones", + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, + "setupNotificationDescription": "Recibe notificaciones cuando las descargas completen o requieran atención.", + "@setupNotificationDescription": { + "description": "Explanation for notifications" + }, + "setupFolderSelected": "¡Carpeta de descarga seleccionada!", + "@setupFolderSelected": { + "description": "Success message for folder selection" + }, + "setupFolderChoose": "Cambiar carpeta de descargas", + "@setupFolderChoose": { + "description": "Button to choose folder" + }, + "setupFolderDescription": "Seleccione una carpeta donde se guardará la música descargada.", + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, + "setupChangeFolder": "Cambiar carpeta", + "@setupChangeFolder": { + "description": "Button to change selected folder" + }, + "setupSelectFolder": "Seleccionar Carpeta", + "@setupSelectFolder": { + "description": "Button to select folder" + }, + "setupSpotifyApiOptional": "API de Spotify (opcional)", + "@setupSpotifyApiOptional": { + "description": "Spotify API step title" + }, + "setupSpotifyApiDescription": "Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.", + "@setupSpotifyApiDescription": { + "description": "Explanation for Spotify API" + }, + "setupUseSpotifyApi": "Usar API de Spotify", + "@setupUseSpotifyApi": { + "description": "Toggle to enable Spotify API" + }, + "setupEnterCredentialsBelow": "Ingresa tus credenciales a continuación", + "@setupEnterCredentialsBelow": { + "description": "Prompt to enter credentials" + }, + "setupUsingDeezer": "Usando Deezer (no se necesita cuenta)", + "@setupUsingDeezer": { + "description": "Status when using Deezer" + }, + "setupEnterClientId": "Introduzca el ID de cliente de Spotify", + "@setupEnterClientId": { + "description": "Placeholder for client ID field" + }, + "setupEnterClientSecret": "Ingresa el Client Secret de Spotify", + "@setupEnterClientSecret": { + "description": "Placeholder for client secret field" + }, + "setupGetFreeCredentials": "Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.", + "@setupGetFreeCredentials": { + "description": "Info about getting Spotify credentials" + }, + "setupEnableNotifications": "Activar notificaciones", + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, + "setupProceedToNextStep": "Ahora puedes continuar con el siguiente paso.", + "@setupProceedToNextStep": { + "description": "Message after completing a step" + }, + "setupNotificationProgressDescription": "Recibirás notificaciones de progreso de descargas.", + "@setupNotificationProgressDescription": { + "description": "Info about notification usage" + }, + "setupNotificationBackgroundDescription": "Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.", + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, + "setupSkipForNow": "Omitir por ahora", + "@setupSkipForNow": { + "description": "Skip button text" + }, + "setupBack": "Atrás", + "@setupBack": { + "description": "Back button text" + }, + "setupNext": "Siguiente", + "@setupNext": { + "description": "Next button text" + }, + "setupGetStarted": "Empezar", + "@setupGetStarted": { + "description": "Final setup button" + }, + "setupSkipAndStart": "Saltar y empezar", + "@setupSkipAndStart": { + "description": "Skip setup and start app" + }, + "setupAllowAccessToManageFiles": "Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.", + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, + "setupGetCredentialsFromSpotify": "Obtener credenciales de developer.spotify.com", + "@setupGetCredentialsFromSpotify": { + "description": "Link text for Spotify developer portal" + }, + "dialogCancel": "Cancelar", + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, + "dialogOk": "Aceptar", + "@dialogOk": { + "description": "Dialog button - confirm/acknowledge" + }, + "dialogSave": "Guardar", + "@dialogSave": { + "description": "Dialog button - save changes" + }, + "dialogDelete": "Eliminar", + "@dialogDelete": { + "description": "Dialog button - delete item" + }, + "dialogRetry": "Volver a intentar", + "@dialogRetry": { + "description": "Dialog button - retry action" + }, + "dialogClose": "Cerrar", + "@dialogClose": { + "description": "Dialog button - close dialog" + }, + "dialogYes": "Sí", + "@dialogYes": { + "description": "Dialog button - confirm yes" + }, + "dialogNo": "No", + "@dialogNo": { + "description": "Dialog button - confirm no" + }, + "dialogClear": "Borrar", + "@dialogClear": { + "description": "Dialog button - clear items" + }, + "dialogConfirm": "Confirmar", + "@dialogConfirm": { + "description": "Dialog button - confirm action" + }, + "dialogDone": "Hecho", + "@dialogDone": { + "description": "Dialog button - action completed" + }, + "dialogImport": "Importar", + "@dialogImport": { + "description": "Dialog button - import data" + }, + "dialogDiscard": "Descartar", + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, + "dialogRemove": "Eliminar", + "@dialogRemove": { + "description": "Dialog button - remove item" + }, + "dialogUninstall": "Desinstalar", + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, + "dialogDiscardChanges": "¿Descartar cambios?", + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, + "dialogUnsavedChanges": "Tienes cambios sin guardar. ¿Quieres descartarlos?", + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, + "dialogDownloadFailed": "Descarga fallida", + "@dialogDownloadFailed": { + "description": "Dialog title - download error" + }, + "dialogTrackLabel": "Pista:", + "@dialogTrackLabel": { + "description": "Label for track name in error dialog" + }, + "dialogArtistLabel": "Artista:", + "@dialogArtistLabel": { + "description": "Label for artist name in error dialog" + }, + "dialogErrorLabel": "Error:", + "@dialogErrorLabel": { + "description": "Label for error message" + }, + "dialogClearAll": "Eliminar todo", + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, + "dialogClearAllDownloads": "¿Estás seguro de que quieres borrar todas las descargas?", + "@dialogClearAllDownloads": { + "description": "Dialog message - clear downloads confirmation" + }, + "dialogRemoveFromDevice": "¿Eliminar del dispositivo?", + "@dialogRemoveFromDevice": { + "description": "Dialog title - delete file confirmation" + }, + "dialogRemoveExtension": "Eliminar extensión", + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogRemoveExtensionMessage": "¿Estás seguro de que quieres eliminar esta extensión? Esto no se puede deshacer.", + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, + "dialogUninstallExtension": "¿Desinstalar extensión?", + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogUninstallExtensionMessage": "¿Estás seguro de que quieres eliminar {extensionName}?", + "@dialogUninstallExtensionMessage": { + "description": "Dialog message - uninstall specific extension", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "dialogClearHistoryTitle": "Borrar historial", + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, + "dialogClearHistoryMessage": "¿Estás seguro de que quieres borrar todo el historial de descargas? Esta acción no se puede deshacer.", + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, + "dialogDeleteSelectedTitle": "Borrar Seleccionados", + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, + "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", + "@dialogDeleteSelectedMessage": { + "description": "Dialog message - delete selected tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dialogImportPlaylistTitle": "Importar lista de reproducción", + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, + "dialogImportPlaylistMessage": "Se han encontrado pistas {count} en CSV. ¿Añadirlas para descargar la cola?", + "@dialogImportPlaylistMessage": { + "description": "Dialog message - import playlist confirmation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAddedToQueue": "Añadido \"{trackName}\" a la cola", + "@snackbarAddedToQueue": { + "description": "Snackbar - track added to download queue", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarAddedTracksToQueue": "Añadidas pistas {count} a la cola", + "@snackbarAddedTracksToQueue": { + "description": "Snackbar - multiple tracks added to queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAlreadyDownloaded": "\"{trackName}\" ya descargado", + "@snackbarAlreadyDownloaded": { + "description": "Snackbar - track already exists", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarHistoryCleared": "Historial borrado", + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, + "snackbarCredentialsSaved": "Credenciales guardadas", + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, + "snackbarCredentialsCleared": "Credenciales borradas", + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, + "snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}", + "@snackbarDeletedTracks": { + "description": "Snackbar - tracks deleted", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarCannotOpenFile": "No se puede abrir el archivo: {error}", + "@snackbarCannotOpenFile": { + "description": "Snackbar - file open error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarFillAllFields": "Por favor, completa todos los campos", + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, + "snackbarViewQueue": "Ver cola", + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" + }, + "snackbarFailedToLoad": "Error al cargar: {error}", + "@snackbarFailedToLoad": { + "description": "Snackbar - loading error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarUrlCopied": "URL {platform} copiada al portapapeles", + "@snackbarUrlCopied": { + "description": "Snackbar - URL copied", + "placeholders": { + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } + } + }, + "snackbarFileNotFound": "Archivo no encontrado", + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, + "snackbarSelectExtFile": "Por favor, seleccione un archivo .spotiflac-ext", + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, + "snackbarProviderPrioritySaved": "Prioridad de proveedor guardada", + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, + "snackbarMetadataProviderSaved": "Prioridad de proveedor de metadatos guardada", + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, + "snackbarExtensionInstalled": "{extensionName} instalado.", + "@snackbarExtensionInstalled": { + "description": "Snackbar - extension installed successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarExtensionUpdated": "{extensionName} actualizada.", + "@snackbarExtensionUpdated": { + "description": "Snackbar - extension updated successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarFailedToInstall": "Fallo al instalar la extensión", + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, + "snackbarFailedToUpdate": "Error al actualizar la extensión", + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, + "errorRateLimited": "Límite Excedido", + "@errorRateLimited": { + "description": "Error title - too many requests" + }, + "errorRateLimitedMessage": "Demasiadas solicitudes. Por favor, espere un momento antes de buscar de nuevo.", + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" + }, + "errorFailedToLoad": "Error al cargar {item}", + "@errorFailedToLoad": { + "description": "Error message - loading failed", + "placeholders": { + "item": { + "type": "String", + "description": "Item that failed to load (album/playlist/etc)" + } + } + }, + "errorNoTracksFound": "No se encontraron pistas", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión", + "@errorMissingExtensionSource": { + "description": "Error - extension source not available", + "placeholders": { + "item": { + "type": "String" + } + } + }, + "statusQueued": "En cola", + "@statusQueued": { + "description": "Download status - waiting in queue" + }, + "statusDownloading": "Descargando", + "@statusDownloading": { + "description": "Download status - in progress" + }, + "statusFinalizing": "Finalizando", + "@statusFinalizing": { + "description": "Download status - writing metadata" + }, + "statusCompleted": "Completado", + "@statusCompleted": { + "description": "Download status - finished" + }, + "statusFailed": "Error", + "@statusFailed": { + "description": "Download status - error occurred" + }, + "statusSkipped": "Omitido", + "@statusSkipped": { + "description": "Download status - already exists" + }, + "statusPaused": "Pausado", + "@statusPaused": { + "description": "Download status - paused" + }, + "actionPause": "Pausar", + "@actionPause": { + "description": "Action button - pause download" + }, + "actionResume": "Reanudar", + "@actionResume": { + "description": "Action button - resume download" + }, + "actionCancel": "Cancelar", + "@actionCancel": { + "description": "Action button - cancel operation" + }, + "actionStop": "Detener", + "@actionStop": { + "description": "Action button - stop operation" + }, + "actionSelect": "Seleccionar", + "@actionSelect": { + "description": "Action button - enter selection mode" + }, + "actionSelectAll": "Seleccionar Todo", + "@actionSelectAll": { + "description": "Action button - select all items" + }, + "actionDeselect": "Deseleccionar", + "@actionDeselect": { + "description": "Action button - deselect all" + }, + "actionPaste": "Pegar", + "@actionPaste": { + "description": "Action button - paste from clipboard" + }, + "actionImportCsv": "Importar CSV", + "@actionImportCsv": { + "description": "Action button - import CSV file" + }, + "actionRemoveCredentials": "Eliminar credenciales", + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, + "actionSaveCredentials": "Guardar credenciales", + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, + "selectionSelected": "{count} seleccionado", + "@selectionSelected": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionAllSelected": "Todas las pistas seleccionadas", + "@selectionAllSelected": { + "description": "Status - all items selected" + }, + "selectionTapToSelect": "Toca las pistas para seleccionar", + "@selectionTapToSelect": { + "description": "Hint - how to select items" + }, + "selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", + "@selectionDeleteTracks": { + "description": "Delete button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionSelectToDelete": "Seleccionar pistas a eliminar", + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "progressFetchingMetadata": "Obteniendo metadatos... {current}/{total}", + "@progressFetchingMetadata": { + "description": "Progress indicator - loading track info", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "progressReadingCsv": "Leyendo CSV...", + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Canciones", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artistas", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Álbumes", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Listas de reproducción", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Reproducir", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "tooltipCancel": "Cancelar", + "@tooltipCancel": { + "description": "Tooltip - cancel button" + }, + "tooltipStop": "Detener", + "@tooltipStop": { + "description": "Tooltip - stop button" + }, + "tooltipRetry": "Volver a intentar", + "@tooltipRetry": { + "description": "Tooltip - retry button" + }, + "tooltipRemove": "Eliminar", + "@tooltipRemove": { + "description": "Tooltip - remove button" + }, + "tooltipClear": "Borrar", + "@tooltipClear": { + "description": "Tooltip - clear button" + }, + "tooltipPaste": "Pegar", + "@tooltipPaste": { + "description": "Tooltip - paste button" + }, + "filenameFormat": "Formato del nombre del archivo", + "@filenameFormat": { + "description": "Setting title - filename pattern" + }, + "filenameFormatPreview": "Vista previa: {preview}", + "@filenameFormatPreview": { + "description": "Preview of filename pattern", + "placeholders": { + "preview": { + "type": "String" + } + } + }, + "filenameAvailablePlaceholders": "Marcadores disponibles:", + "@filenameAvailablePlaceholders": { + "description": "Label for placeholder list" + }, + "filenameHint": "{artist} - {title}", + "@filenameHint": { + "description": "Default filename format hint" + }, + "folderOrganization": "Organización de carpetas", + "@folderOrganization": { + "description": "Setting title - folder structure" + }, + "folderOrganizationNone": "Ninguna organización", + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, + "folderOrganizationByArtist": "Por Artista", + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, + "folderOrganizationByAlbum": "Por Álbum", + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, + "folderOrganizationByArtistAlbum": "Artista/Álbum", + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, + "folderOrganizationDescription": "Organizar los archivos descargados en carpetas", + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, + "folderOrganizationNoneSubtitle": "Todos los archivos de la carpeta de descargas", + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, + "folderOrganizationByArtistSubtitle": "Carpeta separada para cada artista", + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, + "folderOrganizationByAlbumSubtitle": "Carpeta separada para cada artista", + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, + "folderOrganizationByArtistAlbumSubtitle": "Carpetas organizadas por artista y álbum", + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, + "updateAvailable": "Actualización Disponible", + "@updateAvailable": { + "description": "Update dialog title" + }, + "updateNewVersion": "Versión {version} está disponible", + "@updateNewVersion": { + "description": "Update available message", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateDownload": "Descargar", + "@updateDownload": { + "description": "Update button - download update" + }, + "updateLater": "Más tarde", + "@updateLater": { + "description": "Update button - dismiss" + }, + "updateChangelog": "Historial de cambios", + "@updateChangelog": { + "description": "Link to changelog" + }, + "updateStartingDownload": "Iniciando descarga...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Descarga fallida", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Error al descargar la actualización", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "Una nueva versión está lista", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Actual", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "Nuevo", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Descargando...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "Novedades", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Descargar & Instalar", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "No recordar", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriority": "Prioridad del proveedor", + "@providerPriority": { + "description": "Setting title - download provider order" + }, + "providerPrioritySubtitle": "Arrastre para reordenar los proveedores de descarga", + "@providerPrioritySubtitle": { + "description": "Subtitle for provider priority" + }, + "providerPriorityTitle": "Prioridad del proveedor", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Arrastra para reordenar los proveedores de descarga. La aplicación intentará usar los proveedores de arriba hacia abajo al descargar las pistas.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "Si una pista no está disponible en el primer proveedor, la aplicación intentará automáticamente el siguiente.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Integrado", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Extensión", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriority": "Prioridad del proveedor de metadatos", + "@metadataProviderPriority": { + "description": "Setting title - metadata provider order" + }, + "metadataProviderPrioritySubtitle": "Orden usado al recuperar metadatos de la pista", + "@metadataProviderPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "metadataProviderPriorityTitle": "Prioridad de los metadatos", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Arrastra para reordenar los proveedores de metadatos. La aplicación probará los proveedores de arriba hacia abajo al buscar pistas y obtener los metadatos.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "Deezer no tiene límites de tasa y se recomienda como principal. Spotify puede valorar el límite después de muchas solicitudes.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "Sin límites de tasa", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "Sin límites de tasa", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Registros", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopy": "Copiar Registros", + "@logCopy": { + "description": "Action - copy logs to clipboard" + }, + "logClear": "Limpiar registros", + "@logClear": { + "description": "Action - delete all logs" + }, + "logShare": "Compartir Registros", + "@logShare": { + "description": "Action - share logs file" + }, + "logEmpty": "No hay registros aún", + "@logEmpty": { + "description": "Empty state title" + }, + "logCopied": "Registros copiados al portapapeles", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Buscar registros...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Nivel", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filtrar", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Compartir registros", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Borrar registros", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Limpiar registros", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "¿Estás seguro que deseas limpiar todos los registros?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logIspBlocking": "BLOQUEO POR EL ISP DETECTADO", + "@logIspBlocking": { + "description": "Error category - ISP blocking" + }, + "logRateLimited": "TASA LIMITADA", + "@logRateLimited": { + "description": "Error category - rate limiting" + }, + "logNetworkError": "ERROR DE RED", + "@logNetworkError": { + "description": "Error category - network issues" + }, + "logTrackNotFound": "PISTA NO ENCONTRADA", + "@logTrackNotFound": { + "description": "Error category - missing tracks" + }, + "logFilterBySeverity": "Filtrar los registros por gravedad", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "No hay registros aún", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Los registros aparecerán aquí mientras usas la aplicación", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" + }, + "logIssueSummary": "Resumen de Incidencias", + "@logIssueSummary": { + "description": "Section header for error summary" + }, + "logIspBlockingDescription": "Tu ISP puede estar bloqueando el acceso a los servicios de descarga", + "@logIspBlockingDescription": { + "description": "ISP blocking explanation" + }, + "logIspBlockingSuggestion": "Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8", + "@logIspBlockingSuggestion": { + "description": "ISP blocking fix suggestion" + }, + "logRateLimitedDescription": "Demasiadas solicitudes al servicio", + "@logRateLimitedDescription": { + "description": "Rate limit explanation" + }, + "logRateLimitedSuggestion": "Espere unos minutos antes de volver a intentarlo", + "@logRateLimitedSuggestion": { + "description": "Rate limit fix suggestion" + }, + "logNetworkErrorDescription": "Problemas de conexión detectados", + "@logNetworkErrorDescription": { + "description": "Network error explanation" + }, + "logNetworkErrorSuggestion": "Comprueba tu conexión a internet", + "@logNetworkErrorSuggestion": { + "description": "Network error fix suggestion" + }, + "logTrackNotFoundDescription": "No se pudieron encontrar algunas pistas en los servicios de descarga", + "@logTrackNotFoundDescription": { + "description": "Track not found explanation" + }, + "logTrackNotFoundSuggestion": "La pista puede no estar disponible en calidad sin pérdida", + "@logTrackNotFoundSuggestion": { + "description": "Track not found explanation" + }, + "logTotalErrors": "Total de errores: {count}", + "@logTotalErrors": { + "description": "Error count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logAffected": "Afectado: {domains}", + "@logAffected": { + "description": "Affected domains display", + "placeholders": { + "domains": { + "type": "String" + } + } + }, + "logEntriesFiltered": "Entradas ({count} filtradas)", + "@logEntriesFiltered": { + "description": "Log count with filter active", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logEntries": "Entradas ({count})", + "@logEntries": { + "description": "Total log count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "credentialsTitle": "Credenciales de Spotify", + "@credentialsTitle": { + "description": "Credentials dialog title" + }, + "credentialsDescription": "Introduzca su ID de cliente y secreto para utilizar su propia cuota de aplicación de Spotify.", + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, + "credentialsClientId": "ID del cliente", + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, + "credentialsClientIdHint": "Pegar ID de cliente", + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, + "credentialsClientSecret": "Client Secret", + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, + "credentialsClientSecretHint": "Pegar Client Secret", + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, + "channelStable": "Estable", + "@channelStable": { + "description": "Update channel - stable releases" + }, + "channelPreview": "Vista previa", + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, + "sectionSearchSource": "Buscar Fuente", + "@sectionSearchSource": { + "description": "Settings section header" + }, + "sectionDownload": "Descargar", + "@sectionDownload": { + "description": "Settings section header" + }, + "sectionPerformance": "Alto rendimiento", + "@sectionPerformance": { + "description": "Settings section header" + }, + "sectionApp": "Aplicación", + "@sectionApp": { + "description": "Settings section header" + }, + "sectionData": "Datos", + "@sectionData": { + "description": "Settings section header" + }, + "sectionDebug": "Depuración", + "@sectionDebug": { + "description": "Settings section header" + }, + "sectionService": "Servicio", + "@sectionService": { + "description": "Settings section header" + }, + "sectionAudioQuality": "Calidad de Sonido", + "@sectionAudioQuality": { + "description": "Settings section header" + }, + "sectionFileSettings": "Ajustes del archivo", + "@sectionFileSettings": { + "description": "Settings section header" + }, + "sectionColor": "Colores", + "@sectionColor": { + "description": "Settings section header" + }, + "sectionTheme": "Tema", + "@sectionTheme": { + "description": "Settings section header" + }, + "sectionLayout": "Diseño", + "@sectionLayout": { + "description": "Settings section header" + }, + "sectionLanguage": "Idioma", + "@sectionLanguage": { + "description": "Settings section header for language" + }, + "appearanceLanguage": "Idioma de la aplicación", + "@appearanceLanguage": { + "description": "Language setting title" + }, + "appearanceLanguageSubtitle": "Elija su idioma preferido", + "@appearanceLanguageSubtitle": { + "description": "Language setting subtitle" + }, + "settingsAppearanceSubtitle": "Tema, colores, pantalla", + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, + "settingsDownloadSubtitle": "Servicio, calidad, formato del nombre del archivo", + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, + "settingsOptionsSubtitle": "Alternativa, letras, carátula, actualizaciones", + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, + "settingsExtensionsSubtitle": "Administrar proveedores de descarga", + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, + "settingsLogsSubtitle": "Ver registros de aplicaciones para depuración", + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, + "loadingSharedLink": "Cargando enlace compartido...", + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, + "pressBackAgainToExit": "Presione de nuevo para salir", + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, + "tracksHeader": "Pistas", + "@tracksHeader": { + "description": "Section header for track list" + }, + "downloadAllCount": "Descargar Todo ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", + "@tracksCount": { + "description": "Track count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trackCopyFilePath": "Copiar ruta de archivo", + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Eliminar del dispositivo", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Cargar letras", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadatos", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "Información de archivo", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Letras", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "Archivo no encontrado", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Abrir en Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Abrir en Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Nombre de pista", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artista", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Artista del álbum", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Álbum", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Número de pista", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Número de disco", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Duración", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Calidad del sonido", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Fecha de lanzamiento", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackDownloaded": "Descargado", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Copiar letras", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Letras no disponibles para este tema", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Tiempo de espera agotado. Inténtalo de nuevo más tarde.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Error al cargar la letra", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackCopiedToClipboard": "Copiado al portapapeles", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "¿Eliminar del dispositivo?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "Esto eliminará permanentemente el archivo descargado y lo eliminará de tu historial.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" + }, + "trackCannotOpen": "No se puede abrir: {message}", + "@trackCannotOpen": { + "description": "Error opening file", + "placeholders": { + "message": { + "type": "String" + } + } + }, + "dateToday": "Hoy", + "@dateToday": { + "description": "Relative date - today" + }, + "dateYesterday": "Ayer", + "@dateYesterday": { + "description": "Relative date - yesterday" + }, + "dateDaysAgo": "Hace {count} días", + "@dateDaysAgo": { + "description": "Relative date - days ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateWeeksAgo": "{count} semanas antes", + "@dateWeeksAgo": { + "description": "Relative date - weeks ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateMonthsAgo": "{count} meses atrás", + "@dateMonthsAgo": { + "description": "Relative date - months ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "concurrentSequential": "Secuencial", + "@concurrentSequential": { + "description": "Download mode - one at a time" + }, + "concurrentParallel2": "2 simultáneamente", + "@concurrentParallel2": { + "description": "Download mode - 2 simultaneous" + }, + "concurrentParallel3": "3 simultáneamente", + "@concurrentParallel3": { + "description": "Download mode - 3 simultaneous" + }, + "tapToSeeError": "Pulse para ver los detalles del error", + "@tapToSeeError": { + "description": "Tooltip for failed download" + }, + "storeFilterAll": "Todo", + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, + "storeFilterMetadata": "Metadatos", + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, + "storeFilterDownload": "Descargar", + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, + "storeFilterUtility": "Utilidad", + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, + "storeFilterLyrics": "Letras", + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, + "storeFilterIntegration": "Integración", + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, + "storeClearFilters": "Limpiar filtros", + "@storeClearFilters": { + "description": "Button to clear all filters" + }, + "storeNoResults": "No se encontraron extensiones", + "@storeNoResults": { + "description": "Empty state when no extensions match filters" + }, + "extensionProviderPriority": "Prioridad del proveedor", + "@extensionProviderPriority": { + "description": "Extension capability - provider priority" + }, + "extensionInstallButton": "Instalar extensión", + "@extensionInstallButton": { + "description": "Button to install extension" + }, + "extensionDefaultProvider": "Por defecto (Deezer/Spotify)", + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, + "extensionDefaultProviderSubtitle": "Usar búsqueda integrada", + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, + "extensionAuthor": "Autor/a", + "@extensionAuthor": { + "description": "Extension detail - author" + }, + "extensionId": "ID", + "@extensionId": { + "description": "Extension detail - unique ID" + }, + "extensionError": "Error", + "@extensionError": { + "description": "Extension detail - error message" + }, + "extensionCapabilities": "Recursos", + "@extensionCapabilities": { + "description": "Section header - extension features" + }, + "extensionMetadataProvider": "Proveedor de metadatos", + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, + "extensionDownloadProvider": "Proveedor de descargas", + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, + "extensionLyricsProvider": "Proveedor de letras", + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, + "extensionUrlHandler": "Gestor de URL", + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, + "extensionQualityOptions": "Opciones de calidad", + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, + "extensionPostProcessingHooks": "Hooks post-procesamiento", + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, + "extensionPermissions": "Permisos", + "@extensionPermissions": { + "description": "Section header - required permissions" + }, + "extensionSettings": "Ajustes", + "@extensionSettings": { + "description": "Section header - extension settings" + }, + "extensionRemoveButton": "Eliminar extensión", + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, + "extensionUpdated": "Actualizado", + "@extensionUpdated": { + "description": "Extension detail - last update" + }, + "extensionMinAppVersion": "Versión Mínima de la aplicación", + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, + "extensionCustomTrackMatching": "Coincidencia de pista personalizada", + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, + "extensionPostProcessing": "Post-Procesamiento", + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, + "extensionHooksAvailable": "{count} hook(s) disponibles", + "@extensionHooksAvailable": { + "description": "Post-processing hooks count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionPatternsCount": "Patrón(es) {count}", + "@extensionPatternsCount": { + "description": "URL patterns count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionStrategy": "Estrategia: {strategy}", + "@extensionStrategy": { + "description": "Track matching strategy name", + "placeholders": { + "strategy": { + "type": "String" + } + } + }, + "extensionsProviderPrioritySection": "Prioridad del proveedor", + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, + "extensionsInstalledSection": "Extensiones instaladas", + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, + "extensionsNoExtensions": "No hay extensiones instaladas", + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, + "extensionsNoExtensionsSubtitle": "Instalar archivos .spotiflac-ext para añadir nuevos proveedores", + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsInstallButton": "Instalar extensión", + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, + "extensionsInfoTip": "Las extensiones pueden añadir nuevos metadatos y proveedores de descargas. Sólo instalar extensiones desde fuentes confiables.", + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, + "extensionsInstalledSuccess": "Extensión instalada correctamente", + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, + "extensionsDownloadPriority": "Prioridad de descarga", + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, + "extensionsDownloadPrioritySubtitle": "Establecer orden de servicio de descarga", + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, + "extensionsNoDownloadProvider": "No hay extensiones con proveedor de descargas", + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, + "extensionsMetadataPriority": "Prioridad de los metadatos", + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, + "extensionsMetadataPrioritySubtitle": "Establecer orden de búsqueda y metadatos", + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "extensionsNoMetadataProvider": "No hay extensiones con el proveedor de metadatos", + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, + "extensionsSearchProvider": "Proveedor de búsqueda", + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, + "extensionsNoCustomSearch": "No hay extensiones con búsqueda personalizada", + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, + "extensionsSearchProviderDescription": "Elegir qué servicio usar para buscar pistas", + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, + "extensionsCustomSearch": "Búsqueda personalizada", + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, + "extensionsErrorLoading": "Error al cargar la extensión", + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, + "qualityFlacLossless": "FLAC Lossless", + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, + "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, + "qualityHiResFlac": "Hi-Res FLAC", + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, + "qualityHiResFlacSubtitle": "24 bits/hasta 96kHz", + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, + "qualityHiResFlacMax": "Hi-Res FLAC Max", + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, + "qualityHiResFlacMaxSubtitle": "24 bits / hasta 192kHz", + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, + "qualityNote": "La calidad real depende de la disponibilidad de la pista del servicio", + "@qualityNote": { + "description": "Note about quality availability" + }, + "downloadAskBeforeDownload": "Preguntar antes de descargar", + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, + "downloadDirectory": "Carpeta de descarga", + "@downloadDirectory": { + "description": "Setting - download folder" + }, + "downloadSeparateSinglesFolder": "Carpeta separada para pistas", + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, + "downloadAlbumFolderStructure": "Estructura de carpeta del álbum", + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, + "downloadSaveFormat": "Guardar Formato", + "@downloadSaveFormat": { + "description": "Setting - output file format" + }, + "downloadSelectService": "Seleccionar Servicio", + "@downloadSelectService": { + "description": "Dialog title - choose download service" + }, + "downloadSelectQuality": "Seleccionar Calidad", + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, + "downloadFrom": "Descargar Desde", + "@downloadFrom": { + "description": "Label - download source" + }, + "downloadDefaultQualityLabel": "Calidad por Defecto", + "@downloadDefaultQualityLabel": { + "description": "Label - default quality setting" + }, + "downloadBestAvailable": "La mejor disponible", + "@downloadBestAvailable": { + "description": "Quality option - highest available" + }, + "folderNone": "Ninguna", + "@folderNone": { + "description": "Folder option - no organization" + }, + "folderNoneSubtitle": "Guardar todos los archivos directamente para descargar la carpeta", + "@folderNoneSubtitle": { + "description": "Subtitle for no folder organization" + }, + "folderArtist": "Artista", + "@folderArtist": { + "description": "Folder option - by artist" + }, + "folderArtistSubtitle": "Nombre del Artista/nombre de archivo", + "@folderArtistSubtitle": { + "description": "Folder structure example" + }, + "folderAlbum": "Álbum", + "@folderAlbum": { + "description": "Folder option - by album" + }, + "folderAlbumSubtitle": "Nombre del álbum/nombre de archivo", + "@folderAlbumSubtitle": { + "description": "Folder structure example" + }, + "folderArtistAlbum": "Artista/Álbum", + "@folderArtistAlbum": { + "description": "Folder option - nested" + }, + "folderArtistAlbumSubtitle": "Nombre del Artista/Nombre del Álbum/Nombre del Archivo", + "@folderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "serviceTidal": "Tidal", + "@serviceTidal": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceQobuz": "Qobuz", + "@serviceQobuz": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceAmazon": "Amazon", + "@serviceAmazon": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceDeezer": "Deezer", + "@serviceDeezer": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceSpotify": "Spotify", + "@serviceSpotify": { + "description": "Service name - DO NOT TRANSLATE" + }, + "appearanceAmoledDark": "AMOLED Oscuro", + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, + "appearanceAmoledDarkSubtitle": "Fondo negro puro", + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "appearanceChooseAccentColor": "Elegir color principal", + "@appearanceChooseAccentColor": { + "description": "Color picker dialog title" + }, + "appearanceChooseTheme": "Modo de tema", + "@appearanceChooseTheme": { + "description": "Theme picker dialog title" + }, + "queueTitle": "Descargas en proceso", + "@queueTitle": { + "description": "Queue screen title" + }, + "queueClearAll": "Eliminar todo", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, + "queueClearAllMessage": "¿Estás seguro de que quieres borrar todas las descargas?", + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, + "queueEmpty": "No hay descargas en cola", + "@queueEmpty": { + "description": "Empty queue state title" + }, + "queueEmptySubtitle": "Añadir pistas desde la pantalla de inicio", + "@queueEmptySubtitle": { + "description": "Empty queue state subtitle" + }, + "queueClearCompleted": "Limpiar tareas finalizadas", + "@queueClearCompleted": { + "description": "Button - clear finished downloads" + }, + "queueDownloadFailed": "Descarga fallida", + "@queueDownloadFailed": { + "description": "Error dialog title" + }, + "queueTrackLabel": "Pista:", + "@queueTrackLabel": { + "description": "Label in error dialog" + }, + "queueArtistLabel": "Artista:", + "@queueArtistLabel": { + "description": "Label in error dialog" + }, + "queueErrorLabel": "Error:", + "@queueErrorLabel": { + "description": "Label in error dialog" + }, + "queueUnknownError": "Error desconocido", + "@queueUnknownError": { + "description": "Fallback error message" + }, + "albumFolderArtistAlbum": "Artista / Álbum", + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, + "albumFolderArtistAlbumSubtitle": "Álbumes/Nombre del Artista/Nombre del Álbum/", + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderArtistYearAlbum": "Artista / [Año] Álbum", + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderArtistYearAlbumSubtitle": "Álbumes/Nombre del Artista /[2005] Nombre del Álbum/", + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderAlbumOnly": "Sólo álbum", + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, + "albumFolderAlbumOnlySubtitle": "Álbumes/Nombre del Álbum/", + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, + "albumFolderYearAlbum": "Álbum [Año]", + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderYearAlbumSubtitle": "Álbumes/[2005] Nombre del Álbum/", + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "downloadedAlbumDeleteSelected": "Borrar Seleccionados", + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, + "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", + "@downloadedAlbumDeleteMessage": { + "description": "Delete confirmation with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumTracksHeader": "Pistas", + "@downloadedAlbumTracksHeader": { + "description": "Section header for tracks" + }, + "downloadedAlbumDownloadedCount": "{count} descargado", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectedCount": "{count} seleccionado", + "@downloadedAlbumSelectedCount": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumAllSelected": "Todas las pistas seleccionadas", + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, + "downloadedAlbumTapToSelect": "Toca las pistas para seleccionar", + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, + "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", + "@downloadedAlbumDeleteCount": { + "description": "Delete button text with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectToDelete": "Seleccionar pistas a eliminar", + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "utilityFunctions": "Funciones de utilidad", + "@utilityFunctions": { + "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artista", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Álbum", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Canción", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Lista de reproducción", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Lista de reproducción: {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" + } + } + } +} \ No newline at end of file diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 7de05fc5..3e5f8961 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -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" + } + } } } \ No newline at end of file diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 048dd1ed..d286a4ea 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -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" + } + } } } \ No newline at end of file diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 577f7a41..5c97f0de 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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", diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index cc0ccc73..92bee94b 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -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" + } + } } } \ No newline at end of file diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 62e26100..c762fd9f 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -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" + } + } } } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb new file mode 100644 index 00000000..cc4dbc02 --- /dev/null +++ b/lib/l10n/arb/app_pt_PT.arb @@ -0,0 +1,2615 @@ +{ + "@@locale": "pt_PT", + "@@last_modified": "2026-01-16", + "appName": "SpotiFLAC", + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, + "appDescription": "Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.", + "@appDescription": { + "description": "App description shown in about page" + }, + "navHome": "Início", + "@navHome": { + "description": "Bottom navigation - Home tab" + }, + "navHistory": "Histórico", + "@navHistory": { + "description": "Bottom navigation - History tab" + }, + "navSettings": "Configurações", + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, + "navStore": "Loja", + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, + "homeTitle": "Início", + "@homeTitle": { + "description": "Home screen title" + }, + "homeSearchHint": "Pesquise ou cole a URL do Spotify...", + "@homeSearchHint": { + "description": "Placeholder text in search box" + }, + "homeSearchHintExtension": "Pesquisar com {extensionName}...", + "@homeSearchHintExtension": { + "description": "Placeholder when extension search is active", + "placeholders": { + "extensionName": { + "type": "String", + "description": "Name of the active extension" + } + } + }, + "homeSubtitle": "Cole um link do Spotify ou procure por nome", + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, + "homeSupports": "Suporte: Faixas, Álbuns, Playlists, URLs de Artista", + "@homeSupports": { + "description": "Info text about supported URL types" + }, + "homeRecent": "Recentes", + "@homeRecent": { + "description": "Section header for recent searches" + }, + "historyTitle": "Histórico", + "@historyTitle": { + "description": "History screen title" + }, + "historyDownloading": "Baixando ({count})", + "@historyDownloading": { + "description": "Tab showing active downloads count", + "placeholders": { + "count": { + "type": "int", + "description": "Number of active downloads" + } + } + }, + "historyDownloaded": "Baixados", + "@historyDownloaded": { + "description": "Tab showing completed downloads" + }, + "historyFilterAll": "Tudo", + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, + "historyFilterAlbums": "Álbuns", + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, + "historyFilterSingles": "Singles", + "@historyFilterSingles": { + "description": "Filter chip - show singles only" + }, + "historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", + "@historyTracksCount": { + "description": "Track count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}", + "@historyAlbumsCount": { + "description": "Album count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyNoDownloads": "Nenhum histórico de downloads", + "@historyNoDownloads": { + "description": "Empty state title" + }, + "historyNoDownloadsSubtitle": "As faixas baixadas aparecerão aqui", + "@historyNoDownloadsSubtitle": { + "description": "Empty state subtitle" + }, + "historyNoAlbums": "Sem álbuns baixados", + "@historyNoAlbums": { + "description": "Empty state when filtering albums" + }, + "historyNoAlbumsSubtitle": "Baixe várias faixas de um álbum para vê-las aqui", + "@historyNoAlbumsSubtitle": { + "description": "Empty state subtitle for albums filter" + }, + "historyNoSingles": "Sem singles baixados", + "@historyNoSingles": { + "description": "Empty state when filtering singles" + }, + "historyNoSinglesSubtitle": "Os downloads de faixa individuais aparecerão aqui", + "@historyNoSinglesSubtitle": { + "description": "Empty state subtitle for singles filter" + }, + "settingsTitle": "Configurações", + "@settingsTitle": { + "description": "Settings screen title" + }, + "settingsDownload": "Download", + "@settingsDownload": { + "description": "Settings section - download options" + }, + "settingsAppearance": "Aparência", + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, + "settingsOptions": "Opções", + "@settingsOptions": { + "description": "Settings section - app options" + }, + "settingsExtensions": "Extensões", + "@settingsExtensions": { + "description": "Settings section - extension management" + }, + "settingsAbout": "Sobre", + "@settingsAbout": { + "description": "Settings section - app info" + }, + "downloadTitle": "Download", + "@downloadTitle": { + "description": "Download settings page title" + }, + "downloadLocation": "Local dos Downloads", + "@downloadLocation": { + "description": "Setting for download folder" + }, + "downloadLocationSubtitle": "Escolha onde salvar os arquivos", + "@downloadLocationSubtitle": { + "description": "Subtitle for download location" + }, + "downloadLocationDefault": "Local padrão", + "@downloadLocationDefault": { + "description": "Shown when using default folder" + }, + "downloadDefaultService": "Serviço Padrão", + "@downloadDefaultService": { + "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" + }, + "downloadDefaultServiceSubtitle": "Serviço usado para downloads", + "@downloadDefaultServiceSubtitle": { + "description": "Subtitle for default service" + }, + "downloadDefaultQuality": "Qualidade Predefinida", + "@downloadDefaultQuality": { + "description": "Setting for audio quality" + }, + "downloadAskQuality": "Perguntar qualidade antes de baixar", + "@downloadAskQuality": { + "description": "Toggle to show quality picker" + }, + "downloadAskQualitySubtitle": "Mostrar seletor de qualidade para cada download", + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, + "downloadFilenameFormat": "Formato do Nome do Arquivo", + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, + "downloadFolderOrganization": "Organização de Pastas", + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, + "downloadSeparateSingles": "Separar Singles", + "@downloadSeparateSingles": { + "description": "Toggle to separate single tracks" + }, + "downloadSeparateSinglesSubtitle": "Colocar singles numa pasta separada", + "@downloadSeparateSinglesSubtitle": { + "description": "Subtitle for separate singles toggle" + }, + "qualityBest": "Melhor Disponível", + "@qualityBest": { + "description": "Audio quality option - highest available" + }, + "qualityFlac": "FLAC", + "@qualityFlac": { + "description": "Audio quality option - FLAC lossless" + }, + "quality320": "320 kbps", + "@quality320": { + "description": "Audio quality option - 320kbps MP3" + }, + "quality128": "128 kbps", + "@quality128": { + "description": "Audio quality option - 128kbps MP3" + }, + "appearanceTitle": "Aparência", + "@appearanceTitle": { + "description": "Appearance settings page title" + }, + "appearanceTheme": "Tema", + "@appearanceTheme": { + "description": "Theme mode setting" + }, + "appearanceThemeSystem": "Sistema", + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, + "appearanceThemeLight": "Claro", + "@appearanceThemeLight": { + "description": "Light theme" + }, + "appearanceThemeDark": "Escuro", + "@appearanceThemeDark": { + "description": "Dark theme" + }, + "appearanceDynamicColor": "Cores Dinâmicas", + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, + "appearanceDynamicColorSubtitle": "Usar cores do seu papel de parede", + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, + "appearanceAccentColor": "Cor de Destaque", + "@appearanceAccentColor": { + "description": "Custom accent color picker" + }, + "appearanceHistoryView": "Visualização do Histórico", + "@appearanceHistoryView": { + "description": "Layout style for history" + }, + "appearanceHistoryViewList": "Lista", + "@appearanceHistoryViewList": { + "description": "List layout option" + }, + "appearanceHistoryViewGrid": "Grade", + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, + "optionsTitle": "Opções", + "@optionsTitle": { + "description": "Options settings page title" + }, + "optionsSearchSource": "Origem da Pesquisa", + "@optionsSearchSource": { + "description": "Section for search provider settings" + }, + "optionsPrimaryProvider": "Provedor Primário", + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, + "optionsPrimaryProviderSubtitle": "Serviço usado ao pesquisar por nome da faixa.", + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, + "optionsUsingExtension": "Usando a extensão: {extensionName}", + "@optionsUsingExtension": { + "description": "Shows active extension name", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "optionsSwitchBack": "Toque no Deezer ou Spotify para alternar de volta da extensão", + "@optionsSwitchBack": { + "description": "Hint to switch back to built-in providers" + }, + "optionsAutoFallback": "Fallback Automático", + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, + "optionsAutoFallbackSubtitle": "Tentar outros serviços se o download falhar", + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, + "optionsUseExtensionProviders": "Usar Provedores de Extensão", + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, + "optionsUseExtensionProvidersOn": "Extensões serão tentadas primeiro", + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, + "optionsUseExtensionProvidersOff": "Usando apenas provedores integrados", + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, + "optionsEmbedLyrics": "Incorporar Letras", + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, + "optionsEmbedLyricsSubtitle": "Incorporar letras sincronizadas aos arquivos FLAC", + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, + "optionsMaxQualityCover": "Capa de Qualidade Máxima", + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, + "optionsMaxQualityCoverSubtitle": "Baixar capa do álbum com a mais alta resolução", + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, + "optionsConcurrentDownloads": "Downloads Simultâneos", + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, + "optionsConcurrentSequential": "Sequencial (1 por vez)", + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, + "optionsConcurrentParallel": "{count} downloads paralelos", + "@optionsConcurrentParallel": { + "description": "Multiple parallel downloads", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "optionsConcurrentWarning": "Downloads simultâneos podem causar um limite da taxa (ratelimit)", + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, + "optionsExtensionStore": "Loja de Extensões", + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, + "optionsExtensionStoreSubtitle": "Mostrar aba da Loja na navegação", + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, + "optionsCheckUpdates": "Procurar Atualizações", + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, + "optionsCheckUpdatesSubtitle": "Notificar quando uma nova versão estiver disponível", + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, + "optionsUpdateChannel": "Canal de Atualização", + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, + "optionsUpdateChannelStable": "Somente versões estáveis", + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, + "optionsUpdateChannelPreview": "Obter versões de prévia", + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, + "optionsUpdateChannelWarning": "A prévia pode conter erros ou recursos incompletos", + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, + "optionsClearHistory": "Limpar Histórico de Download", + "@optionsClearHistory": { + "description": "Delete all download history" + }, + "optionsClearHistorySubtitle": "Remover todas as faixas baixadas do histórico", + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, + "optionsDetailedLogging": "Registro detalhado", + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, + "optionsDetailedLoggingOn": "Registros detalhados estão sendo gravados", + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, + "optionsDetailedLoggingOff": "Habilitar para relatórios de erros", + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, + "optionsSpotifyCredentials": "Credenciais do Spotify", + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, + "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", + "@optionsSpotifyCredentialsConfigured": { + "description": "Shows configured client ID preview", + "placeholders": { + "clientId": { + "type": "String" + } + } + }, + "optionsSpotifyCredentialsRequired": "Obrigatório - toque para configurar", + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, + "optionsSpotifyWarning": "O Spotify requer as suas próprias credenciais de API. Consiga gratuitamente em developer.spotify.com", + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, + "extensionsTitle": "Extensões", + "@extensionsTitle": { + "description": "Extensions page title" + }, + "extensionsInstalled": "Extensões Instaladas", + "@extensionsInstalled": { + "description": "Section header for installed extensions" + }, + "extensionsNone": "Nenhuma extensão instalada", + "@extensionsNone": { + "description": "Empty state title" + }, + "extensionsNoneSubtitle": "Instalar extensões a partir da aba Loja", + "@extensionsNoneSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsEnabled": "Habilitado", + "@extensionsEnabled": { + "description": "Extension status - active" + }, + "extensionsDisabled": "Desabilitado", + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, + "extensionsVersion": "Versão {version}", + "@extensionsVersion": { + "description": "Extension version display", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "extensionsAuthor": "por {author}", + "@extensionsAuthor": { + "description": "Extension author credit", + "placeholders": { + "author": { + "type": "String" + } + } + }, + "extensionsUninstall": "Desinstalar", + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, + "extensionsSetAsSearch": "Definir como Provedor de Pesquisa", + "@extensionsSetAsSearch": { + "description": "Use extension for search" + }, + "storeTitle": "Loja de Extensões", + "@storeTitle": { + "description": "Store screen title" + }, + "storeSearch": "Pesquisar extensões...", + "@storeSearch": { + "description": "Store search placeholder" + }, + "storeInstall": "Instalar", + "@storeInstall": { + "description": "Install extension button" + }, + "storeInstalled": "Instalado", + "@storeInstalled": { + "description": "Already installed badge" + }, + "storeUpdate": "Atualizar", + "@storeUpdate": { + "description": "Update available button" + }, + "aboutTitle": "Sobre", + "@aboutTitle": { + "description": "About page title" + }, + "aboutContributors": "Colaboradores", + "@aboutContributors": { + "description": "Section for contributors" + }, + "aboutMobileDeveloper": "Desenvolvedor da versão móvel", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, + "aboutOriginalCreator": "Criador do SpotiFLAC original", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, + "aboutLogoArtist": "O artista talentoso que criou o nosso lindo logotipo do aplicativo!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutSpecialThanks": "Agradecimentos Especiais", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Links", + "@aboutLinks": { + "description": "Section for external links" + }, + "aboutMobileSource": "Código-fonte do app móvel", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, + "aboutPCSource": "Código-fonte do app desktop", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, + "aboutReportIssue": "Reportar um problema", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, + "aboutReportIssueSubtitle": "Reporte qualquer problema que encontrar", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, + "aboutFeatureRequest": "Solicitação de recurso", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, + "aboutFeatureRequestSubtitle": "Sugira novos recursos para o aplicativo", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutSupport": "Apoiar", + "@aboutSupport": { + "description": "Section for support/donation links" + }, + "aboutBuyMeCoffee": "Compre-me um café", + "@aboutBuyMeCoffee": { + "description": "Donation link" + }, + "aboutBuyMeCoffeeSubtitle": "Apoie o desenvolvimento na Ko-fi", + "@aboutBuyMeCoffeeSubtitle": { + "description": "Subtitle for donation" + }, + "aboutApp": "Aplicativo", + "@aboutApp": { + "description": "Section for app info" + }, + "aboutVersion": "Versão", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "O criador da API QQDL e HiFi. Sem esta API, os downloads Tidal não existiriam!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "O criador original do projeto HiFi. A base da integração do Tidal!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutDoubleDouble": "DoubleDouble", + "@aboutDoubleDouble": { + "description": "Name of Amazon API service - DO NOT TRANSLATE" + }, + "aboutDoubleDoubleDesc": "API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!", + "@aboutDoubleDoubleDesc": { + "description": "Credit for DoubleDouble API" + }, + "aboutDabMusic": "DAB Music", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "A melhor API de streaming do Qobuz. Downloads de alta resolução não seriam possíveis sem isso!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, + "aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" + }, + "albumTitle": "Álbum", + "@albumTitle": { + "description": "Album screen title" + }, + "albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", + "@albumTracks": { + "description": "Album track count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "albumDownloadAll": "Baixar Tudo", + "@albumDownloadAll": { + "description": "Button to download all tracks" + }, + "albumDownloadRemaining": "Downloads Restantes", + "@albumDownloadRemaining": { + "description": "Button to download remaining tracks" + }, + "playlistTitle": "Playlist", + "@playlistTitle": { + "description": "Playlist screen title" + }, + "artistTitle": "Artista", + "@artistTitle": { + "description": "Artist screen title" + }, + "artistAlbums": "Álbuns", + "@artistAlbums": { + "description": "Section header for artist albums" + }, + "artistSingles": "Singles e EPs", + "@artistSingles": { + "description": "Section header for singles/EPs" + }, + "artistCompilations": "Compilações", + "@artistCompilations": { + "description": "Section header for compilations" + }, + "artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}", + "@artistReleases": { + "description": "Artist release count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "artistPopular": "Populares", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} ouvintes mensais", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, + "trackMetadataTitle": "Informações da Faixa", + "@trackMetadataTitle": { + "description": "Track metadata screen title" + }, + "trackMetadataArtist": "Artista", + "@trackMetadataArtist": { + "description": "Metadata field - artist name" + }, + "trackMetadataAlbum": "Álbum", + "@trackMetadataAlbum": { + "description": "Metadata field - album name" + }, + "trackMetadataDuration": "Duração", + "@trackMetadataDuration": { + "description": "Metadata field - track length" + }, + "trackMetadataQuality": "Qualidade", + "@trackMetadataQuality": { + "description": "Metadata field - audio quality" + }, + "trackMetadataPath": "Caminho do Arquivo", + "@trackMetadataPath": { + "description": "Metadata field - file location" + }, + "trackMetadataDownloadedAt": "Baixado", + "@trackMetadataDownloadedAt": { + "description": "Metadata field - download date" + }, + "trackMetadataService": "Serviço", + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, + "trackMetadataPlay": "Reproduzir", + "@trackMetadataPlay": { + "description": "Action button - play track" + }, + "trackMetadataShare": "Compartilhar", + "@trackMetadataShare": { + "description": "Action button - share track" + }, + "trackMetadataDelete": "Apagar", + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, + "trackMetadataRedownload": "Baixar Novamente", + "@trackMetadataRedownload": { + "description": "Action button - download again" + }, + "trackMetadataOpenFolder": "Abrir Pasta", + "@trackMetadataOpenFolder": { + "description": "Action button - open containing folder" + }, + "setupTitle": "Bem-vindo ao SpotiFLAC", + "@setupTitle": { + "description": "Setup wizard title" + }, + "setupSubtitle": "Vamos começar", + "@setupSubtitle": { + "description": "Setup wizard subtitle" + }, + "setupStoragePermission": "Permissão de Armazenamento", + "@setupStoragePermission": { + "description": "Storage permission step title" + }, + "setupStoragePermissionSubtitle": "Necessária para salvar arquivos baixados", + "@setupStoragePermissionSubtitle": { + "description": "Explanation for storage permission" + }, + "setupStoragePermissionGranted": "Permissão concedida", + "@setupStoragePermissionGranted": { + "description": "Status when permission granted" + }, + "setupStoragePermissionDenied": "Permissão negada", + "@setupStoragePermissionDenied": { + "description": "Status when permission denied" + }, + "setupGrantPermission": "Conceder Permissão", + "@setupGrantPermission": { + "description": "Button to request permission" + }, + "setupDownloadLocation": "Local do Download", + "@setupDownloadLocation": { + "description": "Download folder step title" + }, + "setupChooseFolder": "Selecionar Pasta", + "@setupChooseFolder": { + "description": "Button to pick folder" + }, + "setupContinue": "Continuar", + "@setupContinue": { + "description": "Continue to next step button" + }, + "setupSkip": "Ignorar por enquanto", + "@setupSkip": { + "description": "Skip current step button" + }, + "setupStorageAccessRequired": "Acesso ao Armazenamento Necessário", + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, + "setupStorageAccessMessage": "O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.", + "@setupStorageAccessMessage": { + "description": "Explanation for storage access" + }, + "setupStorageAccessMessageAndroid11": "O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.", + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, + "setupOpenSettings": "Abrir Configurações", + "@setupOpenSettings": { + "description": "Button to open system settings" + }, + "setupPermissionDeniedMessage": "Permissão negada. Por favor, conceda todas as permissões para continuar.", + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, + "setupPermissionRequired": "Permissão {permissionType} Necessária", + "@setupPermissionRequired": { + "description": "Generic permission required title", + "placeholders": { + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } + } + }, + "setupPermissionRequiredMessage": "A permissão {permissionType} é necessária para a melhor experiência. Você pode alterar isso mais tarde em Configurações.", + "@setupPermissionRequiredMessage": { + "description": "Generic permission required message", + "placeholders": { + "permissionType": { + "type": "String" + } + } + }, + "setupSelectDownloadFolder": "Escolher Pasta de Download", + "@setupSelectDownloadFolder": { + "description": "Folder selection step title" + }, + "setupUseDefaultFolder": "Usar Pasta Padrão?", + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, + "setupNoFolderSelected": "Nenhuma pasta selecionada. Você gostaria de usar a pasta padrão de música?", + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, + "setupUseDefault": "Usar Padrão", + "@setupUseDefault": { + "description": "Button to use default folder" + }, + "setupDownloadLocationTitle": "Local do Download", + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, + "setupDownloadLocationIosMessage": "No iOS, downloads são salvos na pasta Documentos do aplicativo. Você pode acessá-los através do app Arquivos.", + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, + "setupAppDocumentsFolder": "Pasta Documentos do App", + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, + "setupAppDocumentsFolderSubtitle": "Recomendado - acessível através do aplicativo Arquivos", + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, + "setupChooseFromFiles": "Escolher dos Arquivos", + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, + "setupChooseFromFilesSubtitle": "Selecione o iCloud ou outro local", + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "Limitação do iOS: Pastas vazias não podem ser selecionadas. Escolha uma pasta com pelo menos um arquivo.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, + "setupDownloadInFlac": "Download Spotify tracks in FLAC", + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, + "setupStepStorage": "Storage", + "@setupStepStorage": { + "description": "Setup step indicator - storage" + }, + "setupStepNotification": "Notification", + "@setupStepNotification": { + "description": "Setup step indicator - notification" + }, + "setupStepFolder": "Folder", + "@setupStepFolder": { + "description": "Setup step indicator - folder" + }, + "setupStepSpotify": "Spotify", + "@setupStepSpotify": { + "description": "Setup step indicator - Spotify API" + }, + "setupStepPermission": "Permission", + "@setupStepPermission": { + "description": "Setup step indicator - permission" + }, + "setupStorageGranted": "Storage Permission Granted!", + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, + "setupStorageRequired": "Storage Permission Required", + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, + "setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, + "setupNotificationGranted": "Permissão de Notificações Concedida!", + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, + "setupNotificationEnable": "Habilitar Notificações", + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, + "setupNotificationDescription": "Seja notificado quando os downloads completarem ou exigirem atenção.", + "@setupNotificationDescription": { + "description": "Explanation for notifications" + }, + "setupFolderSelected": "Pasta para Download Selecionada!", + "@setupFolderSelected": { + "description": "Success message for folder selection" + }, + "setupFolderChoose": "Escolher Pasta de Download", + "@setupFolderChoose": { + "description": "Button to choose folder" + }, + "setupFolderDescription": "Selecione uma pasta onde as suas músicas baixadas serão salvas.", + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, + "setupChangeFolder": "Alterar Pasta", + "@setupChangeFolder": { + "description": "Button to change selected folder" + }, + "setupSelectFolder": "Seleccionar Pasta", + "@setupSelectFolder": { + "description": "Button to select folder" + }, + "setupSpotifyApiOptional": "API do Spotify (opcional)", + "@setupSpotifyApiOptional": { + "description": "Spotify API step title" + }, + "setupSpotifyApiDescription": "Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.", + "@setupSpotifyApiDescription": { + "description": "Explanation for Spotify API" + }, + "setupUseSpotifyApi": "Usar API do Spotify", + "@setupUseSpotifyApi": { + "description": "Toggle to enable Spotify API" + }, + "setupEnterCredentialsBelow": "Insira as suas credenciais abaixo", + "@setupEnterCredentialsBelow": { + "description": "Prompt to enter credentials" + }, + "setupUsingDeezer": "Usando o Deezer (nenhuma conta necessária)", + "@setupUsingDeezer": { + "description": "Status when using Deezer" + }, + "setupEnterClientId": "Insira o Spotify Client ID", + "@setupEnterClientId": { + "description": "Placeholder for client ID field" + }, + "setupEnterClientSecret": "Insira o Spotify Client Secret", + "@setupEnterClientSecret": { + "description": "Placeholder for client secret field" + }, + "setupGetFreeCredentials": "Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.", + "@setupGetFreeCredentials": { + "description": "Info about getting Spotify credentials" + }, + "setupEnableNotifications": "Habilitar Notificações", + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, + "setupProceedToNextStep": "Você já pode prosseguir para o próximo passo.", + "@setupProceedToNextStep": { + "description": "Message after completing a step" + }, + "setupNotificationProgressDescription": "Você receberá notificações de progresso dos downloads.", + "@setupNotificationProgressDescription": { + "description": "Info about notification usage" + }, + "setupNotificationBackgroundDescription": "Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.", + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, + "setupSkipForNow": "Ignorar por enquanto", + "@setupSkipForNow": { + "description": "Skip button text" + }, + "setupBack": "Voltar", + "@setupBack": { + "description": "Back button text" + }, + "setupNext": "Próximo", + "@setupNext": { + "description": "Next button text" + }, + "setupGetStarted": "Começar", + "@setupGetStarted": { + "description": "Final setup button" + }, + "setupSkipAndStart": "Ignorar e Iniciar", + "@setupSkipAndStart": { + "description": "Skip setup and start app" + }, + "setupAllowAccessToManageFiles": "Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.", + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, + "setupGetCredentialsFromSpotify": "Obter credenciais do developer.spotify.com", + "@setupGetCredentialsFromSpotify": { + "description": "Link text for Spotify developer portal" + }, + "dialogCancel": "Cancelar", + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, + "dialogOk": "OK", + "@dialogOk": { + "description": "Dialog button - confirm/acknowledge" + }, + "dialogSave": "Salvar", + "@dialogSave": { + "description": "Dialog button - save changes" + }, + "dialogDelete": "Apagar", + "@dialogDelete": { + "description": "Dialog button - delete item" + }, + "dialogRetry": "Tentar novamente", + "@dialogRetry": { + "description": "Dialog button - retry action" + }, + "dialogClose": "Fechar", + "@dialogClose": { + "description": "Dialog button - close dialog" + }, + "dialogYes": "Sim", + "@dialogYes": { + "description": "Dialog button - confirm yes" + }, + "dialogNo": "Não", + "@dialogNo": { + "description": "Dialog button - confirm no" + }, + "dialogClear": "Limpar", + "@dialogClear": { + "description": "Dialog button - clear items" + }, + "dialogConfirm": "Confirmar", + "@dialogConfirm": { + "description": "Dialog button - confirm action" + }, + "dialogDone": "Concluído", + "@dialogDone": { + "description": "Dialog button - action completed" + }, + "dialogImport": "Importar", + "@dialogImport": { + "description": "Dialog button - import data" + }, + "dialogDiscard": "Descartar", + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, + "dialogRemove": "Remover", + "@dialogRemove": { + "description": "Dialog button - remove item" + }, + "dialogUninstall": "Desinstalar", + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, + "dialogDiscardChanges": "Descartar Alterações?", + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, + "dialogUnsavedChanges": "Você tem alterações não salvas. Deseja descartá-las?", + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, + "dialogDownloadFailed": "Download Falhou", + "@dialogDownloadFailed": { + "description": "Dialog title - download error" + }, + "dialogTrackLabel": "Faixa:", + "@dialogTrackLabel": { + "description": "Label for track name in error dialog" + }, + "dialogArtistLabel": "Artista:", + "@dialogArtistLabel": { + "description": "Label for artist name in error dialog" + }, + "dialogErrorLabel": "Erro:", + "@dialogErrorLabel": { + "description": "Label for error message" + }, + "dialogClearAll": "Limpar Tudo", + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, + "dialogClearAllDownloads": "Você tem certeza que deseja limpar todos os downloads?", + "@dialogClearAllDownloads": { + "description": "Dialog message - clear downloads confirmation" + }, + "dialogRemoveFromDevice": "Remove from device?", + "@dialogRemoveFromDevice": { + "description": "Dialog title - delete file confirmation" + }, + "dialogRemoveExtension": "Remove Extension", + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, + "dialogUninstallExtension": "Uninstall Extension?", + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", + "@dialogUninstallExtensionMessage": { + "description": "Dialog message - uninstall specific extension", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "dialogClearHistoryTitle": "Clear History", + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, + "dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, + "dialogDeleteSelectedTitle": "Delete Selected", + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, + "dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", + "@dialogDeleteSelectedMessage": { + "description": "Dialog message - delete selected tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dialogImportPlaylistTitle": "Import Playlist", + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, + "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", + "@dialogImportPlaylistMessage": { + "description": "Dialog message - import playlist confirmation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAddedToQueue": "Added \"{trackName}\" to queue", + "@snackbarAddedToQueue": { + "description": "Snackbar - track added to download queue", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarAddedTracksToQueue": "Added {count} tracks to queue", + "@snackbarAddedTracksToQueue": { + "description": "Snackbar - multiple tracks added to queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", + "@snackbarAlreadyDownloaded": { + "description": "Snackbar - track already exists", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarHistoryCleared": "History cleared", + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, + "snackbarCredentialsSaved": "Credentials saved", + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, + "snackbarCredentialsCleared": "Credentials cleared", + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, + "snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", + "@snackbarDeletedTracks": { + "description": "Snackbar - tracks deleted", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarCannotOpenFile": "Cannot open file: {error}", + "@snackbarCannotOpenFile": { + "description": "Snackbar - file open error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarFillAllFields": "Please fill all fields", + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, + "snackbarViewQueue": "View Queue", + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" + }, + "snackbarFailedToLoad": "Failed to load: {error}", + "@snackbarFailedToLoad": { + "description": "Snackbar - loading error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarUrlCopied": "{platform} URL copied to clipboard", + "@snackbarUrlCopied": { + "description": "Snackbar - URL copied", + "placeholders": { + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } + } + }, + "snackbarFileNotFound": "File not found", + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, + "snackbarSelectExtFile": "Please select a .spotiflac-ext file", + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, + "snackbarProviderPrioritySaved": "Provider priority saved", + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, + "snackbarMetadataProviderSaved": "Metadata provider priority saved", + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, + "snackbarExtensionInstalled": "{extensionName} installed.", + "@snackbarExtensionInstalled": { + "description": "Snackbar - extension installed successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarExtensionUpdated": "{extensionName} updated.", + "@snackbarExtensionUpdated": { + "description": "Snackbar - extension updated successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarFailedToInstall": "Failed to install extension", + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, + "snackbarFailedToUpdate": "Failed to update extension", + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, + "errorRateLimited": "Rate Limited", + "@errorRateLimited": { + "description": "Error title - too many requests" + }, + "errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" + }, + "errorFailedToLoad": "Failed to load {item}", + "@errorFailedToLoad": { + "description": "Error message - loading failed", + "placeholders": { + "item": { + "type": "String", + "description": "Item that failed to load (album/playlist/etc)" + } + } + }, + "errorNoTracksFound": "No tracks found", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorMissingExtensionSource": "Cannot load {item}: missing extension source", + "@errorMissingExtensionSource": { + "description": "Error - extension source not available", + "placeholders": { + "item": { + "type": "String" + } + } + }, + "statusQueued": "Queued", + "@statusQueued": { + "description": "Download status - waiting in queue" + }, + "statusDownloading": "Downloading", + "@statusDownloading": { + "description": "Download status - in progress" + }, + "statusFinalizing": "Finalizing", + "@statusFinalizing": { + "description": "Download status - writing metadata" + }, + "statusCompleted": "Completed", + "@statusCompleted": { + "description": "Download status - finished" + }, + "statusFailed": "Failed", + "@statusFailed": { + "description": "Download status - error occurred" + }, + "statusSkipped": "Ignorado", + "@statusSkipped": { + "description": "Download status - already exists" + }, + "statusPaused": "Pausado", + "@statusPaused": { + "description": "Download status - paused" + }, + "actionPause": "Pausar", + "@actionPause": { + "description": "Action button - pause download" + }, + "actionResume": "Retomar", + "@actionResume": { + "description": "Action button - resume download" + }, + "actionCancel": "Cancelar", + "@actionCancel": { + "description": "Action button - cancel operation" + }, + "actionStop": "Parar", + "@actionStop": { + "description": "Action button - stop operation" + }, + "actionSelect": "Selecionar", + "@actionSelect": { + "description": "Action button - enter selection mode" + }, + "actionSelectAll": "Selecionar Tudo", + "@actionSelectAll": { + "description": "Action button - select all items" + }, + "actionDeselect": "Desselecionar", + "@actionDeselect": { + "description": "Action button - deselect all" + }, + "actionPaste": "Colar", + "@actionPaste": { + "description": "Action button - paste from clipboard" + }, + "actionImportCsv": "Importar CSV", + "@actionImportCsv": { + "description": "Action button - import CSV file" + }, + "actionRemoveCredentials": "Remover Credenciais", + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, + "actionSaveCredentials": "Salvar Credenciais", + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, + "selectionSelected": "{count} selecionado(s)", + "@selectionSelected": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionAllSelected": "Todas as faixas selecionadas", + "@selectionAllSelected": { + "description": "Status - all items selected" + }, + "selectionTapToSelect": "Toque nas faixas para selecionar", + "@selectionTapToSelect": { + "description": "Hint - how to select items" + }, + "selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}", + "@selectionDeleteTracks": { + "description": "Delete button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionSelectToDelete": "Selecione as faixas para apagar", + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "progressFetchingMetadata": "Buscando metadados... {current}/{total}", + "@progressFetchingMetadata": { + "description": "Progress indicator - loading track info", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "progressReadingCsv": "Lendo CSV...", + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Músicas", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artistas", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Álbuns", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Playlists", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Reproduzir", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "tooltipCancel": "Cancelar", + "@tooltipCancel": { + "description": "Tooltip - cancel button" + }, + "tooltipStop": "Parar", + "@tooltipStop": { + "description": "Tooltip - stop button" + }, + "tooltipRetry": "Tentar Novamente", + "@tooltipRetry": { + "description": "Tooltip - retry button" + }, + "tooltipRemove": "Remover", + "@tooltipRemove": { + "description": "Tooltip - remove button" + }, + "tooltipClear": "Limpar", + "@tooltipClear": { + "description": "Tooltip - clear button" + }, + "tooltipPaste": "Colar", + "@tooltipPaste": { + "description": "Tooltip - paste button" + }, + "filenameFormat": "Formato do Nome do Arquivo", + "@filenameFormat": { + "description": "Setting title - filename pattern" + }, + "filenameFormatPreview": "Prévia: {preview}", + "@filenameFormatPreview": { + "description": "Preview of filename pattern", + "placeholders": { + "preview": { + "type": "String" + } + } + }, + "filenameAvailablePlaceholders": "Substituições permitidas:", + "@filenameAvailablePlaceholders": { + "description": "Label for placeholder list" + }, + "filenameHint": "{artist} - {title}", + "@filenameHint": { + "description": "Default filename format hint" + }, + "folderOrganization": "Organização de Pastas", + "@folderOrganization": { + "description": "Setting title - folder structure" + }, + "folderOrganizationNone": "Nenhuma organização", + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, + "folderOrganizationByArtist": "Por Artista", + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, + "folderOrganizationByAlbum": "Por Album", + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, + "folderOrganizationByArtistAlbum": "Artista/Álbum", + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, + "folderOrganizationDescription": "Organizar arquivos baixados em pastas", + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, + "folderOrganizationNoneSubtitle": "Todos os arquivos na pasta de download", + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, + "folderOrganizationByArtistSubtitle": "Pasta separada para cada artista", + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, + "folderOrganizationByAlbumSubtitle": "Pasta separada para cada álbum", + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, + "folderOrganizationByArtistAlbumSubtitle": "Pastas aninhadas para artista e álbum", + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, + "updateAvailable": "Atualização Disponível", + "@updateAvailable": { + "description": "Update dialog title" + }, + "updateNewVersion": "A versão {version} está disponível", + "@updateNewVersion": { + "description": "Update available message", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateDownload": "Baixar", + "@updateDownload": { + "description": "Update button - download update" + }, + "updateLater": "Depois", + "@updateLater": { + "description": "Update button - dismiss" + }, + "updateChangelog": "Lista de alterações", + "@updateChangelog": { + "description": "Link to changelog" + }, + "updateStartingDownload": "Iniciando download...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Download falhou", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Falha ao baixar a atualização", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "Uma nova versão está pronta", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Atual", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "Novo", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Baixando...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "Novidades", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Baixar e Instalar", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "Não lembrar", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriority": "Prioridade de Provedor", + "@providerPriority": { + "description": "Setting title - download provider order" + }, + "providerPrioritySubtitle": "Arraste para reordenar os provedores de download", + "@providerPrioritySubtitle": { + "description": "Subtitle for provider priority" + }, + "providerPriorityTitle": "Prioridade de Provedor", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Arraste para reordenar provedores de download. O aplicativo irá tentar provedores de cima para baixo ao baixar as faixas.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "Se uma faixa não estiver disponível no primeiro provedor, o aplicativo irá tentar automaticamente a próxima.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Embutido", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Extensão", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriority": "Prioridade de Provedor de Metadados", + "@metadataProviderPriority": { + "description": "Setting title - metadata provider order" + }, + "metadataProviderPrioritySubtitle": "Ordem usada para obter metadados de faixa", + "@metadataProviderPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "metadataProviderPriorityTitle": "Prioridade de Metadados", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Arraste para reordenar provedores de metadados. O aplicativo tentará provedores de cima para baixo ao procurar por faixas e buscar metadados.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "O Deezer não tem limites de taxa e é recomendado como principal. O Spotify pode limitar a taxa após muitas solicitações.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "Sem limites de taxa", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "Pode ter limites de taxa", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Registros", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopy": "Copiar Registros", + "@logCopy": { + "description": "Action - copy logs to clipboard" + }, + "logClear": "Limpar Registros", + "@logClear": { + "description": "Action - delete all logs" + }, + "logShare": "Compartilhar Registros", + "@logShare": { + "description": "Action - share logs file" + }, + "logEmpty": "Ainda não há registros", + "@logEmpty": { + "description": "Empty state title" + }, + "logCopied": "Registros copiados para área de transferência", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Pesquisar registros...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Nível", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filtro", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Compartilhar registros", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Limpar registros", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Limpar Registros", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "Tem certeza de que deseja limpar todos os registros?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logIspBlocking": "BLOQUEIO DE ISP DETECTADO", + "@logIspBlocking": { + "description": "Error category - ISP blocking" + }, + "logRateLimited": "TAXA LIMITADA (RATELIMITED)", + "@logRateLimited": { + "description": "Error category - rate limiting" + }, + "logNetworkError": "ERRO DE REDE", + "@logNetworkError": { + "description": "Error category - network issues" + }, + "logTrackNotFound": "FAIXA NÃO ENCONTRADA", + "@logTrackNotFound": { + "description": "Error category - missing tracks" + }, + "logFilterBySeverity": "Filtrar registros por gravidade", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "Ainda não há registros", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Os registros aparecerão aqui enquanto você usa o aplicativo", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" + }, + "logIssueSummary": "Resumo do Problemas", + "@logIssueSummary": { + "description": "Section header for error summary" + }, + "logIspBlockingDescription": "O seu provedor pode estar bloqueando o acesso aos serviços de download", + "@logIspBlockingDescription": { + "description": "ISP blocking explanation" + }, + "logIspBlockingSuggestion": "Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8", + "@logIspBlockingSuggestion": { + "description": "ISP blocking fix suggestion" + }, + "logRateLimitedDescription": "Muitas solicitações ao serviço", + "@logRateLimitedDescription": { + "description": "Rate limit explanation" + }, + "logRateLimitedSuggestion": "Aguarde alguns minutos antes de tentar novamente", + "@logRateLimitedSuggestion": { + "description": "Rate limit fix suggestion" + }, + "logNetworkErrorDescription": "Problemas de conexão detectados", + "@logNetworkErrorDescription": { + "description": "Network error explanation" + }, + "logNetworkErrorSuggestion": "Check your internet connection", + "@logNetworkErrorSuggestion": { + "description": "Network error fix suggestion" + }, + "logTrackNotFoundDescription": "Some tracks could not be found on download services", + "@logTrackNotFoundDescription": { + "description": "Track not found explanation" + }, + "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", + "@logTrackNotFoundSuggestion": { + "description": "Track not found explanation" + }, + "logTotalErrors": "Total errors: {count}", + "@logTotalErrors": { + "description": "Error count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logAffected": "Affected: {domains}", + "@logAffected": { + "description": "Affected domains display", + "placeholders": { + "domains": { + "type": "String" + } + } + }, + "logEntriesFiltered": "Entries ({count} filtered)", + "@logEntriesFiltered": { + "description": "Log count with filter active", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logEntries": "Entries ({count})", + "@logEntries": { + "description": "Total log count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "credentialsTitle": "Spotify Credentials", + "@credentialsTitle": { + "description": "Credentials dialog title" + }, + "credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, + "credentialsClientId": "Client ID", + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, + "credentialsClientIdHint": "Colar Client ID", + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, + "credentialsClientSecret": "Client Secret", + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, + "credentialsClientSecretHint": "Colar Client Secret", + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, + "channelStable": "Estável", + "@channelStable": { + "description": "Update channel - stable releases" + }, + "channelPreview": "Prévia", + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, + "sectionSearchSource": "Origem da Pesquisa", + "@sectionSearchSource": { + "description": "Settings section header" + }, + "sectionDownload": "Download", + "@sectionDownload": { + "description": "Settings section header" + }, + "sectionPerformance": "Desempenho", + "@sectionPerformance": { + "description": "Settings section header" + }, + "sectionApp": "Aplicativo", + "@sectionApp": { + "description": "Settings section header" + }, + "sectionData": "Dados", + "@sectionData": { + "description": "Settings section header" + }, + "sectionDebug": "Depuração", + "@sectionDebug": { + "description": "Settings section header" + }, + "sectionService": "Serviço", + "@sectionService": { + "description": "Settings section header" + }, + "sectionAudioQuality": "Qualidade de Áudio", + "@sectionAudioQuality": { + "description": "Settings section header" + }, + "sectionFileSettings": "Configurações de Arquivo", + "@sectionFileSettings": { + "description": "Settings section header" + }, + "sectionColor": "Cor", + "@sectionColor": { + "description": "Settings section header" + }, + "sectionTheme": "Tema", + "@sectionTheme": { + "description": "Settings section header" + }, + "sectionLayout": "Layout", + "@sectionLayout": { + "description": "Settings section header" + }, + "sectionLanguage": "Idioma", + "@sectionLanguage": { + "description": "Settings section header for language" + }, + "appearanceLanguage": "Idioma do aplicativo", + "@appearanceLanguage": { + "description": "Language setting title" + }, + "appearanceLanguageSubtitle": "Escolha o seu idioma preferido", + "@appearanceLanguageSubtitle": { + "description": "Language setting subtitle" + }, + "settingsAppearanceSubtitle": "Tema, cores, exibição", + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, + "settingsDownloadSubtitle": "Serviço, qualidade, formato de nome de arquivo", + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, + "settingsOptionsSubtitle": "Fallback, letras, arte de capa, atualizações", + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, + "settingsExtensionsSubtitle": "Gerenciar provedores de download", + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, + "settingsLogsSubtitle": "Ver logs do app para depuração", + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, + "loadingSharedLink": "Carregando link compartilhado...", + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, + "pressBackAgainToExit": "Pressione voltar novamente para sair", + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, + "tracksHeader": "Faixas", + "@tracksHeader": { + "description": "Section header for track list" + }, + "downloadAllCount": "Baixar Todos ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", + "@tracksCount": { + "description": "Track count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trackCopyFilePath": "Copiar caminho do arquivo", + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Remover do dispositivo", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Carregar Letras", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadados", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "Informações do Arquivo", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Letras", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "Arquivo não encontrado", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Abrir no Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Abrir no Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Nome da faixa", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artista", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Artista do álbum", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Álbum", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Número da faixa", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Número do disco", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Duração", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Qualidade de Áudio", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Data de lançamento", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackDownloaded": "Baixado", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Copy lyrics", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Lyrics not available for this track", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Request timed out. Try again later.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Failed to load lyrics", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackCopiedToClipboard": "Copied to clipboard", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "Remove from device?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" + }, + "trackCannotOpen": "Cannot open: {message}", + "@trackCannotOpen": { + "description": "Error opening file", + "placeholders": { + "message": { + "type": "String" + } + } + }, + "dateToday": "Today", + "@dateToday": { + "description": "Relative date - today" + }, + "dateYesterday": "Yesterday", + "@dateYesterday": { + "description": "Relative date - yesterday" + }, + "dateDaysAgo": "{count} days ago", + "@dateDaysAgo": { + "description": "Relative date - days ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateWeeksAgo": "{count} weeks ago", + "@dateWeeksAgo": { + "description": "Relative date - weeks ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateMonthsAgo": "{count} months ago", + "@dateMonthsAgo": { + "description": "Relative date - months ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "concurrentSequential": "Sequential", + "@concurrentSequential": { + "description": "Download mode - one at a time" + }, + "concurrentParallel2": "2 Parallel", + "@concurrentParallel2": { + "description": "Download mode - 2 simultaneous" + }, + "concurrentParallel3": "3 Parallel", + "@concurrentParallel3": { + "description": "Download mode - 3 simultaneous" + }, + "tapToSeeError": "Tap to see error details", + "@tapToSeeError": { + "description": "Tooltip for failed download" + }, + "storeFilterAll": "All", + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, + "storeFilterMetadata": "Metadata", + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, + "storeFilterDownload": "Download", + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, + "storeFilterUtility": "Utility", + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, + "storeFilterLyrics": "Lyrics", + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, + "storeFilterIntegration": "Integration", + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, + "storeClearFilters": "Clear filters", + "@storeClearFilters": { + "description": "Button to clear all filters" + }, + "storeNoResults": "No extensions found", + "@storeNoResults": { + "description": "Empty state when no extensions match filters" + }, + "extensionProviderPriority": "Provider Priority", + "@extensionProviderPriority": { + "description": "Extension capability - provider priority" + }, + "extensionInstallButton": "Install Extension", + "@extensionInstallButton": { + "description": "Button to install extension" + }, + "extensionDefaultProvider": "Default (Deezer/Spotify)", + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, + "extensionDefaultProviderSubtitle": "Use built-in search", + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, + "extensionAuthor": "Author", + "@extensionAuthor": { + "description": "Extension detail - author" + }, + "extensionId": "ID", + "@extensionId": { + "description": "Extension detail - unique ID" + }, + "extensionError": "Error", + "@extensionError": { + "description": "Extension detail - error message" + }, + "extensionCapabilities": "Capabilities", + "@extensionCapabilities": { + "description": "Section header - extension features" + }, + "extensionMetadataProvider": "Metadata Provider", + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, + "extensionDownloadProvider": "Download Provider", + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, + "extensionLyricsProvider": "Lyrics Provider", + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, + "extensionUrlHandler": "URL Handler", + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, + "extensionQualityOptions": "Quality Options", + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, + "extensionPostProcessingHooks": "Post-Processing Hooks", + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, + "extensionPermissions": "Permissions", + "@extensionPermissions": { + "description": "Section header - required permissions" + }, + "extensionSettings": "Settings", + "@extensionSettings": { + "description": "Section header - extension settings" + }, + "extensionRemoveButton": "Remover Extensão", + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, + "extensionUpdated": "Atualizado", + "@extensionUpdated": { + "description": "Extension detail - last update" + }, + "extensionMinAppVersion": "Versão Mínima do App", + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, + "extensionCustomTrackMatching": "Correspondência de Faixa Personalizada", + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, + "extensionPostProcessing": "Pós-Processamento", + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, + "extensionHooksAvailable": "{count} gancho(s) disponíveis", + "@extensionHooksAvailable": { + "description": "Post-processing hooks count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionPatternsCount": "{count} padrão(ões)", + "@extensionPatternsCount": { + "description": "URL patterns count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionStrategy": "Estratégia: {strategy}", + "@extensionStrategy": { + "description": "Track matching strategy name", + "placeholders": { + "strategy": { + "type": "String" + } + } + }, + "extensionsProviderPrioritySection": "Prioridade de Provedor", + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, + "extensionsInstalledSection": "Extensões Instaladas", + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, + "extensionsNoExtensions": "Nenhuma extensão instalada", + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, + "extensionsNoExtensionsSubtitle": "Instale arquivos .spotiflac-ext para adicionar novos provedores", + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsInstallButton": "Instalar Extensão", + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, + "extensionsInfoTip": "Extensões podem adicionar novos metadados e baixar provedores. Somente instale extensões a partir de fontes confiáveis.", + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, + "extensionsInstalledSuccess": "Extensão instalada com sucesso", + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, + "extensionsDownloadPriority": "Prioridade de Download", + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, + "extensionsDownloadPrioritySubtitle": "Definir ordem do serviço de download", + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, + "extensionsNoDownloadProvider": "Nenhuma extensão com provedor de download", + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, + "extensionsMetadataPriority": "Prioridade de Metadados", + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, + "extensionsMetadataPrioritySubtitle": "Definir ordem de origem de pesquisa e metadados", + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "extensionsNoMetadataProvider": "Nenhuma extensão com provedor de metadados", + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, + "extensionsSearchProvider": "Provedor de Pesquisa", + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, + "extensionsNoCustomSearch": "Nenhuma extensão com pesquisa personalizada", + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, + "extensionsSearchProviderDescription": "Escolha qual serviço utilizar para pesquisar faixas", + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, + "extensionsCustomSearch": "Busca personalizada", + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, + "extensionsErrorLoading": "Erro ao carregar extensão", + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, + "qualityFlacLossless": "FLAC Lossless", + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, + "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, + "qualityHiResFlac": "Hi-Res FLAC", + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, + "qualityHiResFlacSubtitle": "24-bit / até 96kHz", + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, + "qualityHiResFlacMax": "Hi-Res FLAC Max", + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, + "qualityHiResFlacMaxSubtitle": "24-bit / até 192kHz", + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, + "qualityNote": "A qualidade real depende da faixa que estiver disponível no serviço", + "@qualityNote": { + "description": "Note about quality availability" + }, + "downloadAskBeforeDownload": "Perguntar qualidade antes de baixar", + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, + "downloadDirectory": "Pasta de Download", + "@downloadDirectory": { + "description": "Setting - download folder" + }, + "downloadSeparateSinglesFolder": "Pasta de Singles Separada", + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, + "downloadAlbumFolderStructure": "Estrutura da Pasta de Álbum", + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, + "downloadSaveFormat": "Formato para Salvar", + "@downloadSaveFormat": { + "description": "Setting - output file format" + }, + "downloadSelectService": "Selecionar Serviço", + "@downloadSelectService": { + "description": "Dialog title - choose download service" + }, + "downloadSelectQuality": "Selecionar Qualidade", + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, + "downloadFrom": "Baixar De", + "@downloadFrom": { + "description": "Label - download source" + }, + "downloadDefaultQualityLabel": "Qualidade Padrão", + "@downloadDefaultQualityLabel": { + "description": "Label - default quality setting" + }, + "downloadBestAvailable": "Melhor Disponível", + "@downloadBestAvailable": { + "description": "Quality option - highest available" + }, + "folderNone": "Nenhum", + "@folderNone": { + "description": "Folder option - no organization" + }, + "folderNoneSubtitle": "Save all files directly to download folder", + "@folderNoneSubtitle": { + "description": "Subtitle for no folder organization" + }, + "folderArtist": "Artist", + "@folderArtist": { + "description": "Folder option - by artist" + }, + "folderArtistSubtitle": "Artist Name/filename", + "@folderArtistSubtitle": { + "description": "Folder structure example" + }, + "folderAlbum": "Album", + "@folderAlbum": { + "description": "Folder option - by album" + }, + "folderAlbumSubtitle": "Album Name/filename", + "@folderAlbumSubtitle": { + "description": "Folder structure example" + }, + "folderArtistAlbum": "Artist/Album", + "@folderArtistAlbum": { + "description": "Folder option - nested" + }, + "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", + "@folderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "serviceTidal": "Tidal", + "@serviceTidal": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceQobuz": "Qobuz", + "@serviceQobuz": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceAmazon": "Amazon", + "@serviceAmazon": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceDeezer": "Deezer", + "@serviceDeezer": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceSpotify": "Spotify", + "@serviceSpotify": { + "description": "Service name - DO NOT TRANSLATE" + }, + "appearanceAmoledDark": "AMOLED Dark", + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, + "appearanceAmoledDarkSubtitle": "Pure black background", + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "appearanceChooseAccentColor": "Choose Accent Color", + "@appearanceChooseAccentColor": { + "description": "Color picker dialog title" + }, + "appearanceChooseTheme": "Theme Mode", + "@appearanceChooseTheme": { + "description": "Theme picker dialog title" + }, + "queueTitle": "Download Queue", + "@queueTitle": { + "description": "Queue screen title" + }, + "queueClearAll": "Clear All", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, + "queueClearAllMessage": "Are you sure you want to clear all downloads?", + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, + "queueEmpty": "No downloads in queue", + "@queueEmpty": { + "description": "Empty queue state title" + }, + "queueEmptySubtitle": "Add tracks from the home screen", + "@queueEmptySubtitle": { + "description": "Empty queue state subtitle" + }, + "queueClearCompleted": "Clear completed", + "@queueClearCompleted": { + "description": "Button - clear finished downloads" + }, + "queueDownloadFailed": "Download Failed", + "@queueDownloadFailed": { + "description": "Error dialog title" + }, + "queueTrackLabel": "Track:", + "@queueTrackLabel": { + "description": "Label in error dialog" + }, + "queueArtistLabel": "Artist:", + "@queueArtistLabel": { + "description": "Label in error dialog" + }, + "queueErrorLabel": "Error:", + "@queueErrorLabel": { + "description": "Label in error dialog" + }, + "queueUnknownError": "Unknown error", + "@queueUnknownError": { + "description": "Fallback error message" + }, + "albumFolderArtistAlbum": "Artist / Album", + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, + "albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderArtistYearAlbum": "Artist / [Year] Album", + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderAlbumOnly": "Album Only", + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, + "albumFolderAlbumOnlySubtitle": "Albums/Album Name/", + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, + "albumFolderYearAlbum": "[Year] Album", + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "downloadedAlbumDeleteSelected": "Delete Selected", + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, + "downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", + "@downloadedAlbumDeleteMessage": { + "description": "Delete confirmation with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumTracksHeader": "Tracks", + "@downloadedAlbumTracksHeader": { + "description": "Section header for tracks" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectedCount": "{count} selected", + "@downloadedAlbumSelectedCount": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumAllSelected": "All tracks selected", + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, + "downloadedAlbumTapToSelect": "Tap tracks to select", + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, + "downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", + "@downloadedAlbumDeleteCount": { + "description": "Delete button text with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectToDelete": "Select tracks to delete", + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index bff3e1bf..cf315776 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -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": { diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 7e545a0b..5b9f70aa 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-CN", + "@@locale": "zh_CN", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 8526e88f..57beac71 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-TW", + "@@locale": "zh_TW", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { @@ -51,7 +51,7 @@ "@homeSupports": { "description": "Info text about supported URL types" }, - "homeRecent": "Recent", + "homeRecent": "最新的", "@homeRecent": { "description": "Section header for recent searches" }, diff --git a/lib/l10n/supported_locales.dart b/lib/l10n/supported_locales.dart index e170ed11..91b3afae 100644 --- a/lib/l10n/supported_locales.dart +++ b/lib/l10n/supported_locales.dart @@ -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 filteredSupportedLocales = [ 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 filteredLocaleCodes = { 'en', 'ru', + 'es_ES', 'id', - 'ja', - 'de', - 'es', - 'fr', - 'hi', - 'ko', - 'nl', - 'pt', - 'zh', - 'zh_CN', - 'zh_TW', + 'pt_PT', }; diff --git a/lib/main.dart b/lib/main.dart index 1a5439fd..37602511 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 2deb8f8c..fc17705c 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -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 ''; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 6b0b891a..892fd528 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 6094a638..5225c989 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -36,6 +36,8 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson(AppSettings instance) => @@ -67,4 +69,6 @@ Map _$AppSettingsToJson(AppSettings instance) => 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, 'locale': instance.locale, + 'enableMp3Option': instance.enableMp3Option, + 'lyricsMode': instance.lyricsMode, }; diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index 55381fab..6860c67e 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -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 toJson() => { kThemeModeKey: themeMode.name, kUseDynamicColorKey: useDynamicColor, @@ -49,7 +45,6 @@ class ThemeSettings { kUseAmoledKey: useAmoled, }; - /// Create from JSON map factory ThemeSettings.fromJson(Map 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( diff --git a/lib/models/track.dart b/lib/models/track.dart index dda110b7..d2ab69fe 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -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 json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); - /// Check if this track is from an extension bool get isFromExtension => source != null && source!.isNotEmpty; } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 2712251d..989cee9d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -26,6 +26,10 @@ String? _normalizeOptionalString(String? value) { return trimmed; } +final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]'); +final _trailingDotsRegex = RegExp(r'\.+$'); +final _yearRegex = RegExp(r'^(\d{4})'); + class DownloadHistoryItem { final String id; final String trackName; @@ -45,6 +49,9 @@ class DownloadHistoryItem { final String? quality; final int? bitDepth; final int? sampleRate; + final String? genre; + final String? label; + final String? copyright; const DownloadHistoryItem({ required this.id, @@ -65,6 +72,9 @@ class DownloadHistoryItem { this.quality, this.bitDepth, this.sampleRate, + this.genre, + this.label, + this.copyright, }); Map toJson() => { @@ -86,6 +96,9 @@ class DownloadHistoryItem { 'quality': quality, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'genre': genre, + 'label': label, + 'copyright': copyright, }; factory DownloadHistoryItem.fromJson(Map json) => @@ -108,12 +121,15 @@ class DownloadHistoryItem { quality: json['quality'] as String?, bitDepth: json['bitDepth'] as int?, sampleRate: json['sampleRate'] as int?, + genre: json['genre'] as String?, + label: json['label'] as String?, + copyright: json['copyright'] as String?, ); } class DownloadHistoryState { final List items; - final Set _downloadedSpotifyIds; // Cache for O(1) lookup + final Set _downloadedSpotifyIds; DownloadHistoryState({this.items = const []}) : _downloadedSpotifyIds = items @@ -121,7 +137,6 @@ class DownloadHistoryState { .map((item) => item.spotifyId!) .toSet(); - /// Check if a track has been downloaded (by Spotify ID) bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); @@ -132,6 +147,7 @@ class DownloadHistoryState { class DownloadHistoryNotifier extends Notifier { static const _storageKey = 'download_history'; + final Future _prefs = SharedPreferences.getInstance(); bool _isLoaded = false; @override @@ -151,7 +167,7 @@ class DownloadHistoryNotifier extends Notifier { Future _loadFromStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonStr = prefs.getString(_storageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); @@ -176,7 +192,6 @@ class DownloadHistoryNotifier extends Notifier { } } - /// Deduplicate history items by spotifyId, deezerId, or ISRC /// Keeps the most recent entry (first occurrence since list is sorted by date desc) List _deduplicateHistory(List items) { final seen = {}; // key -> index of first occurrence @@ -213,7 +228,7 @@ class DownloadHistoryNotifier extends Notifier { Future _saveToStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonList = state.items.map((e) => e.toJson()).toList(); await prefs.setString(_storageKey, jsonEncode(jsonList)); _historyLog.d('Saved ${state.items.length} items to storage'); @@ -222,7 +237,6 @@ class DownloadHistoryNotifier extends Notifier { } } - /// Force reload from storage (useful after app restart) Future reloadFromStorage() async { await _loadFromStorage(); } @@ -273,7 +287,6 @@ class DownloadHistoryNotifier extends Notifier { _saveToStorage(); } - /// Remove item from history by Spotify ID void removeBySpotifyId(String spotifyId) { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), @@ -282,7 +295,6 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.d('Removed item with spotifyId: $spotifyId'); } - /// Get history item by Spotify ID DownloadHistoryItem? getBySpotifyId(String spotifyId) { return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull; } @@ -302,12 +314,12 @@ class DownloadQueueState { final List items; final DownloadItem? currentDownload; final bool isProcessing; - final bool isPaused; // NEW: pause state + final bool isPaused; final String outputDir; final String filenameFormat; - final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS + final String audioQuality; final bool autoFallback; - final int concurrentDownloads; // 1 = sequential, max 3 + final int concurrentDownloads; const DownloadQueueState({ this.items = const [], @@ -360,17 +372,30 @@ class DownloadQueueState { items.where((i) => i.status == DownloadStatus.downloading).length; } +class _ProgressUpdate { + final DownloadStatus status; + final double progress; + final double? speedMBps; + + const _ProgressUpdate({ + required this.status, + required this.progress, + this.speedMBps, + }); +} + class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; - int _downloadCount = 0; // Counter for connection cleanup - static const _cleanupInterval = 50; // Cleanup every 50 downloads - static const _queueStorageKey = - 'download_queue'; // Storage key for queue persistence + int _downloadCount = 0; + static const _cleanupInterval = 50; + static const _queueStorageKey = 'download_queue'; final NotificationService _notificationService = NotificationService(); - int _totalQueuedAtStart = 0; // Track total items when queue started - int _completedInSession = 0; // Track completed downloads in current session - int _failedInSession = 0; // Track failed downloads in current session + final Future _prefs = SharedPreferences.getInstance(); + int _totalQueuedAtStart = 0; + int _completedInSession = 0; + int _failedInSession = 0; bool _isLoaded = false; + final Set _ensuredDirs = {}; @override DownloadQueueState build() { @@ -386,13 +411,12 @@ class DownloadQueueNotifier extends Notifier { return const DownloadQueueState(); } - /// Load persisted queue from storage (for app restart recovery) Future _loadQueueFromStorage() async { if (_isLoaded) return; _isLoaded = true; try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonStr = prefs.getString(_queueStorageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); @@ -428,10 +452,9 @@ class DownloadQueueNotifier extends Notifier { } } - /// Save current queue to storage (only pending items) Future _saveQueueToStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final pendingItems = state.items .where( @@ -454,7 +477,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Start multi-progress polling for all downloads (sequential and parallel) void _startMultiProgressPolling() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( @@ -463,6 +485,15 @@ class DownloadQueueNotifier extends Notifier { try { final allProgress = await PlatformBridge.getAllDownloadProgress(); final items = allProgress['items'] as Map? ?? {}; + final currentItems = state.items; + final itemsById = {}; + final itemIndexById = {}; + for (int i = 0; i < currentItems.length; i++) { + final item = currentItems[i]; + itemsById[item.id] = item; + itemIndexById[item.id] = i; + } + final progressUpdates = {}; bool hasFinalizingItem = false; String? finalizingTrackName; @@ -470,9 +501,7 @@ class DownloadQueueNotifier extends Notifier { for (final entry in items.entries) { final itemId = entry.key; - final localItem = state.items - .where((i) => i.id == itemId) - .firstOrNull; + final localItem = itemsById[itemId]; if (localItem == null) { continue; } @@ -494,16 +523,13 @@ class DownloadQueueNotifier extends Notifier { final status = itemProgress['status'] as String? ?? 'downloading'; if (status == 'finalizing' && bytesTotal > 0) { - updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); - - final currentItem = state.items - .where((i) => i.id == itemId) - .firstOrNull; - if (currentItem != null) { - hasFinalizingItem = true; - finalizingTrackName = currentItem.track.name; - finalizingArtistName = currentItem.track.artistName; - } + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.finalizing, + progress: 1.0, + ); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; continue; } @@ -518,7 +544,11 @@ class DownloadQueueNotifier extends Notifier { percentage = progressFromBackend; } - updateProgress(itemId, percentage, speedMBps: speedMBps); + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: percentage, + speedMBps: speedMBps, + ); final mbReceived = bytesReceived / (1024 * 1024); final mbTotal = bytesTotal / (1024 * 1024); @@ -534,12 +564,47 @@ class DownloadQueueNotifier extends Notifier { } } + if (progressUpdates.isNotEmpty) { + var updatedItems = currentItems; + bool changed = false; + + for (final entry in progressUpdates.entries) { + final index = itemIndexById[entry.key]; + if (index == null) continue; + final current = updatedItems[index]; + if (current.status == DownloadStatus.skipped || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed) { + continue; + } + final update = entry.value; + final next = current.copyWith( + status: update.status, + progress: update.progress, + speedMBps: update.speedMBps ?? current.speedMBps, + ); + if (current.status != next.status || + current.progress != next.progress || + current.speedMBps != next.speedMBps) { + if (!changed) { + updatedItems = List.from(updatedItems); + changed = true; + } + updatedItems[index] = next; + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + } + } + if (hasFinalizingItem && finalizingTrackName != null) { _notificationService.showDownloadFinalizing( trackName: finalizingTrackName, artistName: finalizingArtistName ?? '', ); - return; // Don't show download progress notification + return; } if (items.isNotEmpty) { @@ -583,12 +648,11 @@ class DownloadQueueNotifier extends Notifier { progress: notifProgress, total: notifTotal > 0 ? notifTotal : 1, queueCount: state.queuedCount, - ).catchError((_) {}); // Ignore errors + ).catchError((_) {}); } } } - } catch (e) { - } + } catch (_) {} }); } @@ -637,11 +701,24 @@ class DownloadQueueNotifier extends Notifier { } } + Future _ensureDirExists(String path, {String? label}) async { + if (_ensuredDirs.contains(path)) return; + final dir = Directory(path); + if (!await dir.exists()) { + await dir.create(recursive: true); + if (label != null) { + _log.d('Created $label: $path'); + } else { + _log.d('Created folder: $path'); + } + } + _ensuredDirs.add(path); + } + void setOutputDir(String dir) { state = state.copyWith(outputDir: dir); } - /// Build output directory based on folder organization setting and separateSingles Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { String baseDir = state.outputDir; final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; @@ -651,11 +728,7 @@ class DownloadQueueNotifier extends Notifier { if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; - final dir = Directory(singlesPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created Singles folder: $singlesPath'); - } + await _ensureDirExists(singlesPath, label: 'Singles folder'); return singlesPath; } else { final albumName = _sanitizeFolderName(track.albumName); @@ -679,11 +752,7 @@ class DownloadQueueNotifier extends Notifier { albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; } - final dir = Directory(albumPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created Album folder: $albumPath'); - } + await _ensureDirExists(albumPath, label: 'Album folder'); return albumPath; } } @@ -711,29 +780,24 @@ class DownloadQueueNotifier extends Notifier { if (subPath.isNotEmpty) { final fullPath = '$baseDir${Platform.pathSeparator}$subPath'; - final dir = Directory(fullPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created folder: $fullPath'); - } + await _ensureDirExists(fullPath); return fullPath; } return baseDir; } - /// Sanitize folder names (remove invalid characters) String _sanitizeFolderName(String name) { return name - .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') - .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots + .replaceAll(_invalidFolderChars, '_') + .replaceAll(_trailingDotsRegex, '') // Remove trailing dots .trim(); } /// Extract year from release date (format: "2005-06-13" or "2005") String? _extractYear(String? releaseDate) { if (releaseDate == null || releaseDate.isEmpty) return null; - final match = RegExp(r'^(\d{4})').firstMatch(releaseDate); + final match = _yearRegex.firstMatch(releaseDate); return match?.group(1); } @@ -794,7 +858,7 @@ class DownloadQueueNotifier extends Notifier { }).toList(); state = state.copyWith(items: [...state.items, ...newItems]); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); if (!state.isProcessing) { Future.microtask(() => _processQueue()); @@ -810,21 +874,32 @@ class DownloadQueueNotifier extends Notifier { String? error, DownloadErrorType? errorType, }) { - final items = state.items.map((item) { - if (item.id == id) { - return item.copyWith( - status: status, - progress: progress ?? item.progress, - speedMBps: speedMBps ?? item.speedMBps, - filePath: filePath, - error: error, - errorType: errorType, - ); - } - return item; - }).toList(); + final items = state.items; + final index = items.indexWhere((item) => item.id == id); + if (index == -1) return; - state = state.copyWith(items: items); + final current = items[index]; + final next = current.copyWith( + status: status, + progress: progress ?? current.progress, + speedMBps: speedMBps ?? current.speedMBps, + filePath: filePath, + error: error, + errorType: errorType, + ); + + if (current.status == next.status && + current.progress == next.progress && + current.speedMBps == next.speedMBps && + current.filePath == next.filePath && + current.error == next.error && + current.errorType == next.errorType) { + return; + } + + final updatedItems = List.from(items); + updatedItems[index] = next; + state = state.copyWith(items: updatedItems); if (status == DownloadStatus.completed || status == DownloadStatus.failed || @@ -834,9 +909,11 @@ class DownloadQueueNotifier extends Notifier { } void updateProgress(String id, double progress, {double? speedMBps}) { - final item = state.items.where((i) => i.id == id).firstOrNull; - if (item == null || - item.status == DownloadStatus.skipped || + final items = state.items; + final index = items.indexWhere((i) => i.id == id); + if (index == -1) return; + final item = items[index]; + if (item.status == DownloadStatus.skipped || item.status == DownloadStatus.completed || item.status == DownloadStatus.failed) { return; @@ -866,15 +943,14 @@ class DownloadQueueNotifier extends Notifier { .toList(); state = state.copyWith(items: items); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); } void clearAll() { state = state.copyWith(items: [], isPaused: false); - _saveQueueToStorage(); // Clear persisted queue + _saveQueueToStorage(); } - /// Pause the download queue void pauseQueue() { if (state.isProcessing && !state.isPaused) { state = state.copyWith(isPaused: true); @@ -883,7 +959,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Resume the download queue void resumeQueue() { if (state.isPaused) { state = state.copyWith(isPaused: false); @@ -894,7 +969,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Toggle pause/resume void togglePause() { if (state.isPaused) { resumeQueue(); @@ -903,7 +977,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Retry a failed or skipped download void retryItem(String id) { final item = state.items.where((i) => i.id == id).firstOrNull; if (item == null) { @@ -940,14 +1013,12 @@ class DownloadQueueNotifier extends Notifier { } } - /// Remove a specific item from queue void removeItem(String id) { final items = state.items.where((item) => item.id != id).toList(); state = state.copyWith(items: items); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); } - /// Run post-processing hooks on a downloaded file Future _runPostProcessingHooks(String filePath, Track track) async { try { final settings = ref.read(settingsProvider); @@ -994,7 +1065,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Upgrade Spotify cover URL to max quality (~2000x2000) /// Same logic as Go backend cover.go String _upgradeToMaxQualityCover(String coverUrl) { const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) @@ -1013,8 +1083,13 @@ class DownloadQueueNotifier extends Notifier { return result; } - /// Embed metadata and cover to a FLAC file after M4A conversion - Future _embedMetadataAndCover(String flacPath, Track track) async { + Future _embedMetadataAndCover( + String flacPath, + Track track, { + String? genre, + String? label, + String? copyright, + }) async { final settings = ref.read(settingsProvider); String? coverPath; @@ -1064,12 +1139,12 @@ class DownloadQueueNotifier extends Notifier { if (track.trackNumber != null) { metadata['TRACKNUMBER'] = track.trackNumber.toString(); - metadata['TRACK'] = track.trackNumber.toString(); // Compatibility + metadata['TRACK'] = track.trackNumber.toString(); } if (track.discNumber != null) { metadata['DISCNUMBER'] = track.discNumber.toString(); - metadata['DISC'] = track.discNumber.toString(); // Compatibility + metadata['DISC'] = track.discNumber.toString(); } if (track.releaseDate != null) { @@ -1081,23 +1156,35 @@ class DownloadQueueNotifier extends Notifier { metadata['ISRC'] = track.isrc!; } + if (genre != null && genre.isNotEmpty) { + metadata['GENRE'] = genre; + _log.d('Adding GENRE: $genre'); + } + if (label != null && label.isNotEmpty) { + metadata['ORGANIZATION'] = label; + _log.d('Adding ORGANIZATION (label): $label'); + } + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + _log.d('Adding COPYRIGHT: $copyright'); + } + _log.d('Metadata map content: $metadata'); try { - // Convert duration from seconds to milliseconds for better lyrics matching final durationMs = track.duration * 1000; final lrcContent = await PlatformBridge.getLyricsLRC( - track.id, // spotifyID + track.id, track.name, track.artistName, - filePath: '', // No local file path yet (processed in memory) + filePath: '', durationMs: durationMs, ); if (lrcContent.isNotEmpty) { metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players + metadata['UNSYNCEDLYRICS'] = lrcContent; _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); } } catch (e) { @@ -1126,15 +1213,158 @@ class DownloadQueueNotifier extends Notifier { if (await coverFile.exists()) { await coverFile.delete(); } - } catch (_) {} + } catch (e) { + _log.w('Failed to cleanup cover file: $e'); + } } } catch (e) { _log.e('Failed to embed metadata: $e'); } } + Future _embedMetadataToMp3( + String mp3Path, + Track track, { + String? genre, + String? label, + String? copyright, + }) async { + final settings = ref.read(settingsProvider); + + String? coverPath; + var coverUrl = track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + try { + if (settings.maxQualityCover) { + coverUrl = _upgradeToMaxQualityCover(coverUrl); + _log.d('Cover URL upgraded to max quality for MP3: $coverUrl'); + } + + final tempDir = await getTemporaryDirectory(); + final uniqueId = + '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; + coverPath = '${tempDir.path}/cover_mp3_$uniqueId.jpg'; + + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse(coverUrl)); + final response = await request.close(); + if (response.statusCode == 200) { + final file = File(coverPath); + final sink = file.openWrite(); + await response.pipe(sink); + await sink.close(); + _log.d('Cover downloaded for MP3: $coverPath'); + } else { + _log.w('Failed to download cover for MP3: HTTP ${response.statusCode}'); + coverPath = null; + } + httpClient.close(); + } catch (e) { + _log.e('Failed to download cover for MP3: $e'); + coverPath = null; + } + } + + try { + final metadata = { + 'TITLE': track.name, + 'ARTIST': track.artistName, + 'ALBUM': track.albumName, + }; + + final albumArtist = _normalizeOptionalString(track.albumArtist) ?? + track.artistName; + metadata['ALBUMARTIST'] = albumArtist; + + if (track.trackNumber != null) { + metadata['TRACKNUMBER'] = track.trackNumber.toString(); + metadata['TRACK'] = track.trackNumber.toString(); + } + + if (track.discNumber != null) { + metadata['DISCNUMBER'] = track.discNumber.toString(); + metadata['DISC'] = track.discNumber.toString(); + } + + if (track.releaseDate != null) { + metadata['DATE'] = track.releaseDate!; + metadata['YEAR'] = track.releaseDate!.split('-').first; + } + + if (track.isrc != null) { + metadata['ISRC'] = track.isrc!; + } + + if (genre != null && genre.isNotEmpty) { + metadata['GENRE'] = genre; + _log.d('Adding GENRE to MP3: $genre'); + } + if (label != null && label.isNotEmpty) { + metadata['ORGANIZATION'] = label; + _log.d('Adding ORGANIZATION (label) to MP3: $label'); + } + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + _log.d('Adding COPYRIGHT to MP3: $copyright'); + } + + _log.d('MP3 Metadata map content: $metadata'); + + if (settings.embedLyrics) { + try { + final durationMs = track.duration * 1000; + + final lrcContent = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: durationMs, + ); + + if (lrcContent.isNotEmpty) { + metadata['LYRICS'] = lrcContent; + metadata['UNSYNCEDLYRICS'] = lrcContent; + _log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)'); + } + } catch (e) { + _log.w('Failed to fetch lyrics for MP3 embedding: $e'); + } + } + + _log.d('Embedding tags to MP3: $metadata'); + + final result = await FFmpegService.embedMetadataToMp3( + mp3Path: mp3Path, + coverPath: coverPath != null && await File(coverPath).exists() + ? coverPath + : null, + metadata: metadata, + ); + + if (result != null) { + _log.d('Metadata, lyrics, and cover embedded to MP3 via FFmpeg'); + } else { + _log.w('FFmpeg MP3 metadata/cover embed failed'); + } + + if (coverPath != null) { + try { + final coverFile = File(coverPath); + if (await coverFile.exists()) { + await coverFile.delete(); + } + } catch (e) { + _log.w('Failed to cleanup MP3 cover file: $e'); + } + } + } catch (e) { + _log.e('Failed to embed metadata to MP3: $e'); + } + } + Future _processQueue() async { - if (state.isProcessing) return; // Prevent multiple concurrent processing + if (state.isProcessing) return; state = state.copyWith(isProcessing: true); _log.i('Starting queue processing...'); @@ -1231,7 +1461,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Sequential download processing (uses multi-progress system with single item) Future _processQueueSequential() async { _startMultiProgressPolling(); @@ -1243,29 +1472,17 @@ class DownloadQueueNotifier extends Notifier { } final currentItems = state.items; - final nextItem = currentItems.firstWhere( + final nextIndex = currentItems.indexWhere( (item) => item.status == DownloadStatus.queued, - orElse: () => DownloadItem( - id: '', - track: const Track( - id: '', - name: '', - artistName: '', - albumName: '', - duration: 0, - ), - service: '', - createdAt: DateTime.now(), - ), ); - - if (nextItem.id.isEmpty) { + if (nextIndex == -1) { _log.d( 'No more items to process (checked ${currentItems.length} items)', ); break; } + final nextItem = currentItems[nextIndex]; _log.d( 'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})', ); @@ -1277,10 +1494,10 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); } - /// Parallel download processing with worker pool Future _processQueueParallel() async { final maxConcurrent = state.concurrentDownloads; - final activeDownloads = >{}; // Map item ID to future + final activeDownloads = >{}; + _startMultiProgressPolling(); @@ -1334,7 +1551,6 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); } - /// Download a single item (used by both sequential and parallel processing) Future _downloadSingleItem(DownloadItem item) async { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); @@ -1397,7 +1613,6 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.albumName, albumArtist: data['album_artist'] as String?, coverUrl: data['images'] as String?, - // duration_ms from Go is in milliseconds, Track.duration is in seconds duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ @@ -1440,6 +1655,33 @@ class DownloadQueueNotifier extends Notifier { final quality = item.qualityOverride ?? state.audioQuality; + // Fetch extended metadata (genre, label) from Deezer if available + String? genre; + String? label; + + String? deezerTrackId = trackToDownload.deezerId; + if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { + deezerTrackId = trackToDownload.id.split(':')[1]; + } + if (deezerTrackId == null && trackToDownload.availability?.deezerId != null) { + deezerTrackId = trackToDownload.availability!.deezerId; + } + + if (deezerTrackId != null && deezerTrackId.isNotEmpty) { + try { + final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId); + if (extendedMetadata != null) { + genre = extendedMetadata['genre']; + label = extendedMetadata['label']; + if (genre != null && genre.isNotEmpty) { + _log.d('Extended metadata - Genre: $genre, Label: $label'); + } + } + } catch (e) { + _log.w('Failed to fetch extended metadata from Deezer: $e'); + } + } + Map result; final extensionState = ref.read(extensionProvider); @@ -1468,7 +1710,10 @@ class DownloadQueueNotifier extends Notifier { releaseDate: trackToDownload.releaseDate, itemId: item.id, durationMs: trackToDownload.duration, - source: trackToDownload.source, // Pass extension ID that provided this track + source: trackToDownload.source, + genre: genre, + label: label, + lyricsMode: settings.lyricsMode, ); } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); @@ -1491,9 +1736,11 @@ class DownloadQueueNotifier extends Notifier { discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, preferredService: item.service, - itemId: item.id, // Pass item ID for progress tracking - durationMs: - trackToDownload.duration, // Duration in ms for verification + itemId: item.id, + durationMs: trackToDownload.duration, + genre: genre, + label: label, + lyricsMode: settings.lyricsMode, ); } else { result = await PlatformBridge.downloadTrack( @@ -1543,8 +1790,10 @@ class DownloadQueueNotifier extends Notifier { if (result['success'] == true) { var filePath = result['file_path'] as String?; - if (filePath != null && filePath.startsWith('EXISTS:')) { + final wasExisting = filePath != null && filePath.startsWith('EXISTS:'); + if (wasExisting) { filePath = filePath.substring(7); // Remove "EXISTS:" prefix + _log.i('Using existing file: $filePath'); } _log.i('Download success, file: $filePath'); @@ -1642,7 +1891,21 @@ class DownloadQueueNotifier extends Notifier { ); } - await _embedMetadataAndCover(flacPath, finalTrack); + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + if (backendGenre != null || backendLabel != null || backendCopyright != null) { + _log.d('Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright'); + } + + await _embedMetadataAndCover( + flacPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); _log.d('Metadata and cover embedded successfully'); } catch (e) { _log.w('Warning: Failed to embed metadata/cover: $e'); @@ -1677,6 +1940,56 @@ class DownloadQueueNotifier extends Notifier { return; } + if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) { + if (wasExisting) { + _log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file'); + } else { + _log.i('MP3 quality selected, converting FLAC to MP3...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.97, + ); + + try { + final mp3Path = await FFmpegService.convertFlacToMp3( + filePath, + bitrate: '320k', + deleteOriginal: true, + ); + + if (mp3Path != null) { + filePath = mp3Path; + actualQuality = 'MP3 320kbps'; + _log.i('Successfully converted to MP3: $mp3Path'); + + _log.i('Embedding metadata to MP3...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); + + final mp3BackendGenre = result['genre'] as String?; + final mp3BackendLabel = result['label'] as String?; + final mp3BackendCopyright = result['copyright'] as String?; + + await _embedMetadataToMp3( + mp3Path, + trackToDownload, + genre: mp3BackendGenre ?? genre, + label: mp3BackendLabel ?? label, + copyright: mp3BackendCopyright, + ); + } else { + _log.w('MP3 conversion failed, keeping FLAC file'); + } + } catch (e) { + _log.e('MP3 conversion error: $e, keeping FLAC file'); + } + } + } + updateItemStatus( item.id, DownloadStatus.completed, @@ -1707,6 +2020,9 @@ class DownloadQueueNotifier extends Notifier { final backendBitDepth = result['actual_bit_depth'] as int?; final backendSampleRate = result['actual_sample_rate'] as int?; final backendISRC = result['isrc'] as String?; + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); @@ -1716,6 +2032,10 @@ class DownloadQueueNotifier extends Notifier { ? normalizedAlbumArtist : null; + final isMp3 = filePath.endsWith('.mp3'); + final historyBitDepth = isMp3 ? null : backendBitDepth; + final historySampleRate = isMp3 ? null : backendSampleRate; + ref .read(downloadHistoryProvider.notifier) .addToHistory( @@ -1750,8 +2070,11 @@ class DownloadQueueNotifier extends Notifier { ? backendYear : trackToDownload.releaseDate, quality: actualQuality, - bitDepth: backendBitDepth, - sampleRate: backendSampleRate, + bitDepth: historyBitDepth, + sampleRate: historySampleRate, + genre: backendGenre, + label: backendLabel, + copyright: backendCopyright, ), ); @@ -1850,3 +2173,22 @@ final downloadQueueProvider = NotifierProvider( DownloadQueueNotifier.new, ); + +class DownloadQueueLookup { + final Map byTrackId; + + DownloadQueueLookup._(this.byTrackId); + + factory DownloadQueueLookup.fromItems(List items) { + final map = {}; + for (final item in items) { + map.putIfAbsent(item.track.id, () => item); + } + return DownloadQueueLookup._(map); + } +} + +final downloadQueueLookupProvider = Provider((ref) { + final items = ref.watch(downloadQueueProvider.select((s) => s.items)); + return DownloadQueueLookup.fromItems(items); +}); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 7086051b..6f74d25b 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -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,19 @@ 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 permissions; final List settings; - final List qualityOptions; // Custom quality options for download providers + final List 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; const Extension({ required this.id, @@ -140,7 +139,6 @@ class Extension { bool get hasPostProcessing => postProcessing?.enabled ?? false; } -/// Custom search behavior configuration class SearchBehavior { final bool enabled; final String? placeholder; @@ -172,8 +170,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 +187,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 +207,6 @@ class TrackMatching { } } -/// Post-processing configuration class PostProcessing { final bool enabled; final List hooks; @@ -262,7 +256,6 @@ class URLHandler { } } -/// A post-processing hook class PostProcessingHook { final String id; final String name; @@ -289,12 +282,11 @@ class PostProcessingHook { } } -/// Represents a quality option for download providers class QualityOption { final String id; final String label; final String? description; - final List settings; // Quality-specific settings + final List settings; const QualityOption({ required this.id, @@ -315,14 +307,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? options; // For select type + final List? options; final bool required; final bool secret; @@ -351,15 +342,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? options; // For select type + final List? options; final bool required; + final String? action; const ExtensionSetting({ required this.key, @@ -369,6 +360,7 @@ class ExtensionSetting { this.description, this.options, this.required = false, + this.action, }); factory ExtensionSetting.fromJson(Map json) { @@ -380,11 +372,11 @@ class ExtensionSetting { description: json['description'] as String?, options: (json['options'] as List?)?.cast(), required: json['required'] as bool? ?? false, + action: json['action'] as String?, ); } } -/// State for extension management class ExtensionState { final List extensions; final List providerPriority; @@ -422,7 +414,6 @@ class ExtensionState { } -/// Provider for managing extensions class ExtensionNotifier extends Notifier { @override ExtensionState build() { @@ -448,7 +439,6 @@ class ExtensionNotifier extends Notifier { } } - /// Load all extensions from directory Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); @@ -483,12 +473,10 @@ class ExtensionNotifier extends Notifier { } } - /// Clear any error state void clearError() { state = state.copyWith(error: null); } - /// Install extension from file (auto-upgrades if already installed with newer version) Future installExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); @@ -505,8 +493,6 @@ class ExtensionNotifier extends Notifier { } } - /// Check if a package file is an upgrade for an existing extension - /// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed} Future> checkExtensionUpgrade(String filePath) async { try { return await PlatformBridge.checkExtensionUpgrade(filePath); @@ -516,7 +502,6 @@ class ExtensionNotifier extends Notifier { } } - /// Upgrade an existing extension from a new package file Future upgradeExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); @@ -550,7 +535,6 @@ class ExtensionNotifier extends Notifier { } } - /// Enable or disable an extension Future setExtensionEnabled(String extensionId, bool enabled) async { try { await PlatformBridge.setExtensionEnabled(extensionId, enabled); @@ -597,7 +581,6 @@ class ExtensionNotifier extends Notifier { } } - /// Update settings for an extension Future setExtensionSettings(String extensionId, Map settings) async { try { await PlatformBridge.setExtensionSettings(extensionId, settings); @@ -618,7 +601,6 @@ class ExtensionNotifier extends Notifier { } } - /// Set provider priority order Future setProviderPriority(List priority) async { try { await PlatformBridge.setProviderPriority(priority); @@ -640,7 +622,6 @@ class ExtensionNotifier extends Notifier { } } - /// Set metadata provider priority order Future setMetadataProviderPriority(List priority) async { try { await PlatformBridge.setMetadataProviderPriority(priority); @@ -662,7 +643,6 @@ class ExtensionNotifier extends Notifier { } } - /// Get extension by ID Extension? getExtension(String extensionId) { try { return state.extensions.firstWhere((ext) => ext.id == extensionId); @@ -676,7 +656,6 @@ class ExtensionNotifier extends Notifier { return state.extensions.where((ext) => ext.enabled).toList(); } - /// Get all download providers (built-in + extensions) List getAllDownloadProviders() { final providers = ['tidal', 'qobuz', 'amazon']; for (final ext in state.extensions) { @@ -697,7 +676,6 @@ class ExtensionNotifier extends Notifier { } return providers; } - /// Get all extensions that provide custom search List get searchProviders { return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); } diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index aad77455..ab0b1466 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -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 items; + final Set hiddenDownloadIds; // IDs of downloads hidden from recents final bool isLoaded; const RecentAccessState({ this.items = const [], + this.hiddenDownloadIds = const {}, this.isLoaded = false, }); RecentAccessState copyWith({ List? items, + Set? hiddenDownloadIds, bool? isLoaded, }) { return RecentAccessState( items: items ?? this.items, + hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds, isLoaded: isLoaded ?? this.isLoaded, ); } @@ -104,19 +109,26 @@ class RecentAccessNotifier extends Notifier { Future _loadHistory() async { final prefs = await SharedPreferences.getInstance(); final json = prefs.getString(_recentAccessKey); + final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); + + List items = []; + Set hiddenIds = {}; + if (json != null) { try { final List decoded = jsonDecode(json); - final items = decoded + items = decoded .map((e) => RecentAccessItem.fromJson(e as Map)) .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 _saveHistory() async { @@ -125,6 +137,11 @@ class RecentAccessNotifier extends Notifier { await prefs.setString(_recentAccessKey, json); } + Future _saveHiddenDownloads() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); + } + /// Record an access to an artist void recordArtistAccess({ required String id, @@ -200,9 +217,6 @@ class RecentAccessNotifier extends Notifier { } 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 +229,6 @@ class RecentAccessNotifier extends Notifier { state = state.copyWith(items: updatedItems); _saveHistory(); - - // ignore: avoid_print - print('[RecentAccess] Total items now: ${updatedItems.length}'); } /// Remove a specific item from history @@ -229,14 +240,31 @@ class RecentAccessNotifier extends Notifier { _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.new, ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a5dd74c1..1e7830f4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -30,7 +30,6 @@ class SettingsNotifier extends Notifier { } } - /// Run one-time migrations for settings Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; @@ -51,7 +50,6 @@ class SettingsNotifier extends Notifier { await prefs.setString(_settingsKey, jsonEncode(state.toJson())); } - /// Apply current Spotify credentials to Go backend Future _applySpotifyCredentials() async { if (state.spotifyClientId.isNotEmpty && state.spotifyClientSecret.isNotEmpty) { @@ -92,6 +90,13 @@ class SettingsNotifier extends Notifier { _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 +228,15 @@ class SettingsNotifier extends Notifier { 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( diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index fe067e5e..6a314cab 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -52,7 +52,6 @@ class StoreCategory { } } -/// Represents an extension in the store class StoreExtension { final String id; final String name; @@ -118,7 +117,6 @@ class StoreExtension { } } -/// State for extension store class StoreState { final List extensions; final String? selectedCategory; @@ -200,7 +198,6 @@ class StoreNotifier extends Notifier { return const StoreState(); } - /// Initialize the store Future initialize(String cacheDir) async { if (state.isInitialized) return; @@ -234,7 +231,6 @@ class StoreNotifier extends Notifier { } } - /// Set category filter void setCategory(String? category) { if (category == null) { state = state.copyWith(clearCategory: true); @@ -248,7 +244,6 @@ class StoreNotifier extends Notifier { state = state.copyWith(searchQuery: query); } - /// Clear search void clearSearch() { state = state.copyWith(searchQuery: '', clearCategory: true); } @@ -279,7 +274,6 @@ class StoreNotifier extends Notifier { } } - /// Update an installed extension Future updateExtension(String extensionId, String tempDir) async { state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); @@ -305,7 +299,6 @@ class StoreNotifier extends Notifier { } } - /// Clear error void clearError() { state = state.copyWith(clearError: true); } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index ca40c032..f1a3e728 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -34,7 +34,6 @@ class ThemeNotifier extends Notifier { ); } catch (e) { debugPrint('Error loading theme settings: $e'); - // Keep default state on error } } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index a37bd60c..074fede8 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -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 { - /// Request ID to track and cancel outdated requests int _currentRequestId = 0; @override @@ -213,14 +210,8 @@ class TrackNotifier extends Notifier { Map 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 { final albumsList = metadata['albums'] as List; final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).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 { } } - /// Perform custom search using a specific extension Future customSearch(String extensionId, String query, {Map? options}) async { final requestId = ++_currentRequestId; @@ -429,7 +419,7 @@ class TrackNotifier extends Notifier { 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 { /// 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 { ); } - /// Pre-warm track ID cache for faster downloads - /// Runs in background, doesn't block UI void _preWarmCacheForTracks(List tracks) { final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); if (tracksWithIsrc.isEmpty) return; diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 0b087645..86db03de 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -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:palette_generator/palette_generator.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'; @@ -11,7 +13,6 @@ 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'; -/// Simple in-memory cache for album tracks class _AlbumCache { static final Map _cache = {}; static const Duration _ttl = Duration(minutes: 10); @@ -37,7 +38,6 @@ 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; @@ -60,11 +60,16 @@ class _AlbumScreenState extends ConsumerState { List? _tracks; bool _isLoading = false; String? _error; + Color? _dominantColor; + bool _showTitleInAppBar = false; + 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( @@ -80,6 +85,40 @@ class _AlbumScreenState extends ConsumerState { if (_tracks == null) { _fetchTracks(); } + + _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 _extractDominantColor() async { + if (widget.coverUrl == null) return; + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(widget.coverUrl!), + maximumColorCount: 16, + ); + if (mounted) { + setState(() { + _dominantColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + }); + } + } catch (_) { + } } Future _fetchTracks() async { @@ -89,12 +128,8 @@ class _AlbumScreenState extends ConsumerState { 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); } @@ -143,6 +178,7 @@ class _AlbumScreenState extends ConsumerState { return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme), @@ -167,74 +203,106 @@ class _AlbumScreenState extends ConsumerState { } 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), @@ -244,6 +312,8 @@ class _AlbumScreenState extends ConsumerState { Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { final tracks = _tracks ?? []; + final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), @@ -260,7 +330,14 @@ class _AlbumScreenState extends ConsumerState { widget.albumName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), ), - const SizedBox(height: 8), + if (artistName != null && artistName.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + artistName, + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + const SizedBox(height: 12), if (tracks.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -363,7 +440,6 @@ class _AlbumScreenState extends ConsumerState { } } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -426,7 +502,6 @@ class _AlbumScreenState extends ConsumerState { } } -/// 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 +512,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); @@ -461,8 +536,8 @@ class _AlbumTrackItem 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)), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index b2427ce9..5dda70bf 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.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'; @@ -95,11 +96,17 @@ class _ArtistScreenState extends ConsumerState { String? _headerImageUrl; int? _monthlyListeners; String? _error; + + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); - @override +@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 +148,24 @@ class _ArtistScreenState extends ConsumerState { } } 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 _fetchDiscography() async { setState(() => _isLoadingDiscography = true); try { @@ -256,8 +278,9 @@ class _ArtistScreenState extends ConsumerState { final singles = albums.where((a) => a.albumType == 'single').toList(); final compilations = albums.where((a) => a.albumType == 'compilation').toList(); - return Scaffold( +return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ _buildHeader(context, colorScheme), if (_isLoadingDiscography) @@ -286,7 +309,6 @@ class _ArtistScreenState extends ConsumerState { ); } - /// Build Spotify-style header with full-width image and artist name overlay Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { String? imageUrl = _headerImageUrl; if (imageUrl == null || imageUrl.isEmpty) { @@ -307,22 +329,38 @@ class _ArtistScreenState extends ConsumerState { listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners)); } - return SliverAppBar( +return SliverAppBar( expandedHeight: 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, ), @@ -439,11 +477,10 @@ class _ArtistScreenState extends ConsumerState { ); } - /// 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 +514,13 @@ class _ArtistScreenState extends ConsumerState { 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 +605,6 @@ class _ArtistScreenState extends ConsumerState { _downloadTrack(track); } - /// Build download button with status indicator for popular tracks Widget _buildPopularDownloadButton({ required Track track, required ColorScheme colorScheme, @@ -713,12 +750,13 @@ class _ArtistScreenState extends ConsumerState { 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, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index c10bb466..6cc2f621 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/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 { bool _isSelectionMode = false; final Set _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 _extractDominantColor() async { + if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return; + + // Only use network images for palette extraction + final isNetworkUrl = widget.coverUrl!.startsWith('http://') || + widget.coverUrl!.startsWith('https://'); + if (!isNetworkUrl) return; + + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(widget.coverUrl!), + maximumColorCount: 16, + ); + if (mounted) { + setState(() { + _dominantColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + }); + } + } catch (_) { + } + } /// Get tracks for this album from history provider (reactive) List _getAlbumTracks(List allItems) { return allItems.where((item) { - final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + // Use albumArtist if available and not empty, otherwise artistName + final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) + ? item.albumArtist! + : item.artistName; + final itemKey = '${item.albumName}|$itemArtist'; final albumKey = '${widget.albumName}|${widget.artistName}'; 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 { }); } + Map> _groupTracksByDisc( + List tracks, + ) { + final discMap = >{}; + 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 { } 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 { )); } + 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 { 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 { body: Stack( children: [ CustomScrollView( + controller: _scrollController, slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme, tracks), @@ -211,69 +298,99 @@ class _DownloadedAlbumScreenState extends ConsumerState { } 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 { } Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List 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 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), + ), + ), + ], ), ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 65c61116..2d89bc5e 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 { } } - 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 { @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 { 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 { ), 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 { ), 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 { 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 { ); } - 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 { 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 { color: colorScheme.onSurfaceVariant, ), ), - onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index), + onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track), ); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index bdabe145..af5413a3 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -4,6 +4,7 @@ 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: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/providers/track_provider.dart'; @@ -17,6 +18,7 @@ import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; +import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -73,7 +75,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - /// Called when trackState changes - used to sync search bar with state void _onTrackStateChanged(TrackState? previous, TrackState next) { if (previous != null && !next.hasContent && @@ -94,7 +95,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (searchProvider == null || searchProvider.isEmpty) return false; - // Check if the extension is enabled and has search capability final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull; return extension != null; } @@ -128,10 +128,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - /// Execute live search with concurrency protection - /// Prevents race conditions in extensions by ensuring only one search runs at a time Future _executeLiveSearch(String query) async { - // If a search is already in progress, queue this one if (_isLiveSearchInProgress) { _pendingLiveSearchQuery = query; return; @@ -149,13 +146,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final pending = _pendingLiveSearchQuery; _pendingLiveSearchQuery = null; - // Execute pending query if it's different from what we just searched - // and still matches current text field content if (pending != null && pending != query && mounted && _urlController.text.trim() == pending) { - // Small delay to let extension's state settle await Future.delayed(const Duration(milliseconds: 100)); if (mounted && _urlController.text.trim() == pending) { _executeLiveSearch(pending); @@ -222,7 +216,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); } - /// Navigate to detail screen based on fetched content type void _navigateToDetailIfNeeded() { final trackState = ref.read(trackProvider); @@ -354,37 +347,63 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // ignore: use_build_context_synchronously final l10n = context.l10n; - final confirmed = await showDialog( - context: this.context, - builder: (dialogCtx) => AlertDialog( - title: Text(l10n.dialogImportPlaylistTitle), - content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogCtx, false), - child: Text(l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogCtx, true), - child: Text(l10n.dialogImport), - ), - ], - ), - ); - - if (confirmed == true) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () { - }, + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + this.context, + trackName: l10n.csvImportTracks(tracks.length), + artistName: l10n.dialogImportPlaylistTitle, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue( + tracks, + service, + qualityOverride: quality, + ); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } + }, + ); + } else { + // Use default settings without quality picker + final confirmed = await showDialog( + context: this.context, + builder: (dialogCtx) => AlertDialog( + title: Text(l10n.dialogImportPlaylistTitle), + content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx, false), + child: Text(l10n.dialogCancel), ), - ), - ); + FilledButton( + onPressed: () => Navigator.pop(dialogCtx, true), + child: Text(l10n.dialogImport), + ), + ], + ), + ); + + if (confirmed == true) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } } } } @@ -529,7 +548,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (showRecentAccess) SliverToBoxAdapter( - child: _buildRecentAccess(recentAccessItems, colorScheme), + child: _buildRecentAccess( + recentAccessItems, + historyItems, + colorScheme, + ), ), SliverToBoxAdapter( @@ -609,13 +632,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ClipRRect( borderRadius: BorderRadius.circular(12), child: item.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, width: 100, height: 100, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, ) : Container( width: 100, @@ -646,21 +670,62 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Build recent access history section (shown when search focused) - Widget _buildRecentAccess(List items, ColorScheme colorScheme) { - final historyItems = ref.read(downloadHistoryProvider).items; + Widget _buildRecentAccess( + List items, + List historyItems, + ColorScheme colorScheme, + ) { + // Group download history by album + final albumGroups = >{}; + for (final h in historyItems) { + final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + final albumKey = '${h.albumName}|$artistForKey'; + albumGroups.putIfAbsent(albumKey, () => []).add(h); + } - final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem( - id: h.spotifyId!, - name: h.trackName, - subtitle: h.artistName, - imageUrl: h.coverUrl, - type: RecentAccessType.track, - accessedAt: h.downloadedAt, - providerId: 'download', - )).toList(); + final downloadItems = []; + for (final entry in albumGroups.entries) { + final tracks = entry.value; + final mostRecent = tracks.reduce((a, b) => + a.downloadedAt.isAfter(b.downloadedAt) ? a : b); + final artistForKey = (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) + ? mostRecent.albumArtist! + : mostRecent.artistName; + + if (tracks.length == 1) { + downloadItems.add(RecentAccessItem( + id: mostRecent.spotifyId ?? mostRecent.id, + name: mostRecent.trackName, + subtitle: mostRecent.artistName, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.track, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + )); + } else { + downloadItems.add(RecentAccessItem( + id: '${mostRecent.albumName}|$artistForKey', + name: mostRecent.albumName, + subtitle: artistForKey, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + )); + } + } - final allItems = [...items, ...downloadItems]; + downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds)); + final visibleDownloads = downloadItems + .where((item) => !hiddenIds.contains(item.id)) + .take(10) + .toList(); + + final allItems = [...items, ...visibleDownloads]; allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); final seen = {}; @@ -671,6 +736,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return true; }).take(10).toList(); + // Check if there are hidden downloads + final hasHiddenDownloads = hiddenIds.isNotEmpty; + return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Column( @@ -685,19 +753,50 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient color: colorScheme.onSurfaceVariant, ), ), - TextButton( - onPressed: () { - ref.read(recentAccessProvider.notifier).clearHistory(); - }, - child: Text( - context.l10n.dialogClearAll, - style: TextStyle(color: colorScheme.primary, fontSize: 12), + if (uniqueItems.isNotEmpty) + TextButton( + onPressed: () { + for (final item in downloadItems) { + ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); + } + ref.read(recentAccessProvider.notifier).clearHistory(); + }, + child: Text( + context.l10n.dialogClearAll, + style: TextStyle(color: colorScheme.primary, fontSize: 12), + ), ), - ), ], ), const SizedBox(height: 8), - ...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), + if (uniqueItems.isEmpty && hasHiddenDownloads) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Icon(Icons.visibility_off, size: 48, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), + const SizedBox(height: 12), + Text( + 'No recent items', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () { + ref.read(recentAccessProvider.notifier).clearHiddenDownloads(); + }, + icon: const Icon(Icons.visibility, size: 18), + label: const Text('Show All Downloads'), + ), + ], + ), + ), + ) + else + ...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), ], ), ); @@ -733,12 +832,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ClipRRect( borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4), child: item.imageUrl != null && item.imageUrl!.isNotEmpty - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.imageUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Container( width: 56, height: 56, @@ -781,7 +881,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient IconButton( icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), onPressed: () { - ref.read(recentAccessProvider.notifier).removeItem(item); + if (item.providerId == 'download') { + ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); + } else { + ref.read(recentAccessProvider.notifier).removeItem(item); + } }, ), ], @@ -815,7 +919,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } case RecentAccessType.album: - if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { + if (item.providerId == 'download') { + Navigator.push(context, MaterialPageRoute( + builder: (context) => DownloadedAlbumScreen( + albumName: item.name, + artistName: item.subtitle ?? '', + coverUrl: item.imageUrl, + ), + )); + } else if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: item.providerId!, @@ -850,6 +962,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } void _navigateToMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push(context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -858,7 +971,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } - /// Build error widget with special handling for rate limit (429) + 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, + ); + } + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -932,10 +1055,28 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } - final realTracks = tracks.where((t) => !t.isCollection).toList(); - final albumItems = tracks.where((t) => t.isAlbumItem).toList(); - final playlistItems = tracks.where((t) => t.isPlaylistItem).toList(); - final artistItems = tracks.where((t) => t.isArtistItem).toList(); + final realTracks = []; + final realTrackIndexes = []; + final albumItems = []; + final playlistItems = []; + final artistItems = []; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks[i]; + if (!track.isCollection) { + realTracks.add(track); + realTrackIndexes.add(i); + } + if (track.isAlbumItem) { + albumItems.add(track); + } + if (track.isPlaylistItem) { + playlistItems.add(track); + } + if (track.isArtistItem) { + artistItems.add(track); + } + } return [ if (error != null) @@ -1078,9 +1219,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _TrackItemWithStatus( key: ValueKey(realTracks[i].id), track: realTracks[i], - index: tracks.indexOf(realTracks[i]), // Use original index for download + index: realTrackIndexes[i], showDivider: i < realTracks.length - 1, - onDownload: () => _downloadTrack(tracks.indexOf(realTracks[i])), + onDownload: () => _downloadTrack(realTrackIndexes[i]), ), ], ), @@ -1140,11 +1281,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), child: ClipOval( child: hasValidImage - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: artist.imageUrl!, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Icon( Icons.person, color: colorScheme.onSurfaceVariant, @@ -1266,7 +1408,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } - /// Get search hint based on selected provider String _getSearchHint() { final settings = ref.read(settingsProvider); final searchProvider = settings.searchProvider; @@ -1311,7 +1452,16 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), - prefixIcon: const Icon(Icons.search), + prefixIcon: _SearchProviderDropdown( + onProviderChanged: () { + _lastSearchQuery = null; + setState(() {}); + final text = _urlController.text.trim(); + if (text.isNotEmpty && text.length >= _minLiveSearchChars) { + _performSearch(text); + } + }, + ), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1341,9 +1491,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Handle Enter key press - search or fetch URL void _onSearchSubmitted() { - // Cancel any pending live search since user explicitly pressed enter _liveSearchDebounce?.cancel(); _pendingLiveSearchQuery = null; @@ -1364,7 +1512,179 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } -/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes +/// Dropdown widget for quick search provider switching +class _SearchProviderDropdown extends ConsumerWidget { + final VoidCallback? onProviderChanged; + + const _SearchProviderDropdown({this.onProviderChanged}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + final currentProvider = settings.searchProvider; + final searchProviders = extState.extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .toList(); + + Extension? currentExt; + if (currentProvider != null && currentProvider.isNotEmpty) { + currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull; + } + + // Determine display icon + IconData displayIcon = Icons.search; + String? iconPath; + if (currentExt != null) { + iconPath = currentExt.iconPath; + if (currentExt.searchBehavior?.icon != null) { + displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); + } + } + + if (searchProviders.isEmpty) { + return const Icon(Icons.search); + } + + return Padding( + padding: const EdgeInsets.only(left: 8), + child: PopupMenuButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconPath != null && iconPath.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon(displayIcon, size: 20), + ), + ) + else + Icon(displayIcon, size: 20), + const SizedBox(width: 2), + Icon( + Icons.arrow_drop_down, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + tooltip: 'Change search provider', + offset: const Offset(0, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (String providerId) { + final provider = providerId.isEmpty ? null : providerId; + ref.read(settingsProvider.notifier).setSearchProvider(provider); + onProviderChanged?.call(); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: '', + child: Row( + children: [ + Icon( + Icons.music_note, + size: 20, + color: currentProvider == null || currentProvider.isEmpty + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + settings.metadataSource == 'spotify' ? 'Spotify' : 'Deezer', + style: TextStyle( + fontWeight: currentProvider == null || currentProvider.isEmpty + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == null || currentProvider.isEmpty) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + if (searchProviders.isNotEmpty) const PopupMenuDivider(), + // Extension providers + ...searchProviders.map((ext) => PopupMenuItem( + value: ext.id, + child: Row( + children: [ + if (ext.iconPath != null && ext.iconPath!.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(ext.iconPath!), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ) + else + Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + ext.displayName, + style: TextStyle( + fontWeight: currentProvider == ext.id + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == ext.id) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + )), + ], + ), + ); + } + + IconData _getIconFromName(String? iconName) { + switch (iconName) { + case 'video': + case 'movie': + return Icons.video_library; + case 'music': + return Icons.music_note; + case 'podcast': + return Icons.podcasts; + case 'book': + case 'audiobook': + return Icons.menu_book; + case 'cloud': + return Icons.cloud; + case 'download': + return Icons.download; + default: + return Icons.search; + } + } +} + class _TrackItemWithStatus extends ConsumerWidget { final Track track; final int index; @@ -1383,9 +1703,9 @@ class _TrackItemWithStatus 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); @@ -1432,13 +1752,14 @@ class _TrackItemWithStatus extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: track.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: track.coverUrl!, width: thumbWidth, height: thumbHeight, fit: BoxFit.cover, memCacheWidth: (thumbWidth * 2).toInt(), memCacheHeight: (thumbHeight * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( width: thumbWidth, @@ -1611,13 +1932,14 @@ class _CollectionItemWidget extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(isArtist ? 28 : 10), child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, ) : Container( width: 56, @@ -1642,7 +1964,9 @@ class _CollectionItemWidget extends StatelessWidget { ), const SizedBox(height: 2), Text( - item.artistName.isNotEmpty ? item.artistName : (isPlaylist ? 'Playlist' : 'Album'), + item.artistName.isNotEmpty + ? item.artistName + : (isPlaylist ? 'Playlist' : (isArtist ? 'Artist' : 'Album')), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -1672,7 +1996,6 @@ class _CollectionItemWidget extends StatelessWidget { } } -/// Screen for viewing extension album with track fetching class ExtensionAlbumScreen extends ConsumerStatefulWidget { final String extensionId; final String albumId; @@ -1943,7 +2266,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState { } } - /// Handle back press with double-tap to exit void _handleBackPress() { final trackState = ref.read(trackProvider); @@ -174,9 +173,6 @@ class _MainShellState extends ConsumerState { 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 { 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; } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 56162437..e64c8daf 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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:palette_generator/palette_generator.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 tracks; @@ -23,16 +24,65 @@ class PlaylistScreen extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _PlaylistScreenState(); +} + +class _PlaylistScreenState extends ConsumerState { + 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 _extractDominantColor() async { + if (widget.coverUrl == null) return; + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(widget.coverUrl!), + maximumColorCount: 16, + ); + if (mounted) { + setState(() { + _dominantColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + }); + } + } catch (_) { + } + } + + @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 +90,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 +211,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,15 +221,15 @@ 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), FilledButton.icon( - onPressed: () => _downloadAll(context, ref), + onPressed: () => _downloadAll(context), icon: const Icon(Icons.download), - label: Text(context.l10n.downloadAllCount(tracks.length)), + label: Text(context.l10n.downloadAllCount(widget.tracks.length)), style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), ), ], @@ -149,25 +255,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 +292,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 +323,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 +347,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)), diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart index cb604cd4..b649d496 100644 --- a/lib/screens/queue_screen.dart +++ b/lib/screens/queue_screen.dart @@ -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( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 2bf54f5f..c8bd4f7e 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4,6 +4,7 @@ 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,7 +13,6 @@ 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; @@ -31,6 +31,20 @@ class _GroupedAlbum { String get key => '$albumName|$artistName'; } +class _HistoryStats { + final Map 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, + }); +} + class QueueTab extends ConsumerStatefulWidget { final PageController? parentPageController; final int parentPageIndex; @@ -93,7 +107,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Enter selection mode with initial item void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { @@ -110,7 +123,6 @@ class _QueueTabState extends ConsumerState { }); } - /// Toggle item selection void _toggleSelection(String itemId) { setState(() { if (_selectedIds.contains(itemId)) { @@ -131,7 +143,6 @@ class _QueueTabState extends ConsumerState { }); } - /// Delete selected items Future _deleteSelected() async { final count = _selectedIds.length; final confirmed = await showDialog( @@ -234,6 +245,17 @@ class _QueueTabState extends ConsumerState { } } + 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 +274,7 @@ class _QueueTabState extends ConsumerState { ), ); + _precacheCover(historyItem.coverUrl); Navigator.push( context, PageRouteBuilder( @@ -266,6 +289,7 @@ class _QueueTabState extends ConsumerState { } void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push( context, PageRouteBuilder( @@ -279,21 +303,13 @@ class _QueueTabState extends ConsumerState { ); } - /// 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 _filterHistoryItems( List items, String filterMode, + Map albumCounts, ) { if (filterMode == 'all') return items; - final albumCounts = {}; - for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - albumCounts[key] = (albumCounts[key] ?? 0) + 1; - } - switch (filterMode) { case 'albums': return items.where((item) { @@ -312,82 +328,56 @@ class _QueueTabState extends ConsumerState { } } - /// Count albums vs singles for filter chips - Map _countAlbumsAndSingles(List items) { + _HistoryStats _buildHistoryStats(List items) { final albumCounts = {}; + final albumMap = >{}; for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; 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 { + 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 items) { - final albumMap = >{}; - - 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 items) { - final albumKeys = {}; - 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) { @@ -435,11 +425,10 @@ class _QueueTabState extends ConsumerState { 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 = _buildHistoryStats(allHistoryItems); + final groupedAlbums = historyStats.groupedAlbums; + final albumCount = historyStats.albumCount; + final singleCount = historyStats.singleTracks; final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -679,6 +668,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), _buildFilterContent( context: context, @@ -688,6 +678,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), _buildFilterContent( context: context, @@ -697,6 +688,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), ], ), @@ -713,7 +705,11 @@ class _QueueTabState extends ConsumerState { child: _buildSelectionBottomBar( context, colorScheme, - _filterHistoryItems(allHistoryItems, historyFilterMode), + _filterHistoryItems( + allHistoryItems, + historyFilterMode, + historyStats.albumCounts, + ), bottomPadding, ), ), @@ -722,7 +718,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Build content for each filter tab Widget _buildFilterContent({ required BuildContext context, required ColorScheme colorScheme, @@ -731,8 +726,10 @@ class _QueueTabState extends ConsumerState { required String historyViewMode, required List queueItems, required List<_GroupedAlbum> groupedAlbums, + required Map albumCounts, }) { - final historyItems = _filterHistoryItems(allHistoryItems, filterMode); + final historyItems = + _filterHistoryItems(allHistoryItems, filterMode, albumCounts); return CustomScrollView( slivers: [ @@ -926,7 +923,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Build album grid item for grouped albums view Widget _buildAlbumGridItem( BuildContext context, _GroupedAlbum album, @@ -943,13 +939,14 @@ class _QueueTabState extends ConsumerState { 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 +1242,14 @@ class _QueueTabState extends ConsumerState { 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 +1402,12 @@ class _QueueTabState extends ConsumerState { 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 +1612,14 @@ class _QueueTabState extends ConsumerState { 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 +1736,6 @@ class _QueueTabState extends ConsumerState { } } -/// Filter chip widget for history filtering class _FilterChip extends StatelessWidget { final String label; final int count; diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 39789ddf..0788ecb9 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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 { } } - 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 { ), ), 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 { ); } - 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 { ), trailing: IconButton( icon: Icon(Icons.download, color: colorScheme.primary), - onPressed: () => _downloadTrack(index), + onPressed: () => _downloadTrack(track), ), - onTap: () => _downloadTrack(index), + onTap: () => _downloadTrack(track), ); } } diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 83e2f401..a68d1f03 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -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), ), @@ -245,9 +253,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 +334,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 +404,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 _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; diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 1f467c8d..92ca5ff0 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -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,23 @@ 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), ('zh', '简体中文', Icons.language), + ('zh_CN', '简体中文 (中国)', Icons.language), ('zh_TW', '繁體中文', Icons.language), ]; diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 042db0af..f408c64d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -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: [ @@ -585,6 +627,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, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 34571577..1f27ef88 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -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 { 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 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( - 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 _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), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 3c603de6..7c9036db 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -581,7 +581,7 @@ class _SetupScreenState extends ConsumerState { switch (step) { case 0: return _storagePermissionGranted; case 1: return _selectedDirectory != null; - case 2: return false; // Spotify step never shows checkmark (optional) + case 2: return false; } } return false; diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 9053f546..2114777f 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -122,7 +122,7 @@ class _StoreTabState extends ConsumerState { ), onChanged: (value) { ref.read(storeProvider.notifier).setSearchQuery(value); - setState(() {}); // Update suffix icon + setState(() {}); }, ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 67693d2a..caaa6343 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; @@ -11,8 +13,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -/// Screen to display detailed metadata for a downloaded track -/// Designed with Material Expressive 3 style class TrackMetadataScreen extends ConsumerStatefulWidget { final DownloadHistoryItem item; @@ -28,6 +28,25 @@ class _TrackMetadataScreenState extends ConsumerState { String? _lyrics; bool _lyricsLoading = false; String? _lyricsError; + Color? _dominantColor; + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); + static final RegExp _lrcTimestampPattern = + RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); + static const List _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; String? _normalizeOptionalString(String? value) { if (value == null) return null; @@ -40,7 +59,47 @@ class _TrackMetadataScreenState extends ConsumerState { @override void initState() { super.initState(); + _scrollController.addListener(_onScroll); _checkFile(); + _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 _extractDominantColor() async { + final coverUrl = widget.item.coverUrl; + if (coverUrl == null || coverUrl.isEmpty) return; + if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) { + return; + } + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(coverUrl), + size: const Size(128, 128), + maximumColorCount: 12, + ); + final nextColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + if (mounted && nextColor != _dominantColor) { + setState(() { + _dominantColor = nextColor; + }); + } + } catch (_) { + } } Future _checkFile() async { @@ -48,26 +107,26 @@ class _TrackMetadataScreenState extends ConsumerState { if (filePath.startsWith('EXISTS:')) { filePath = filePath.substring(7); } - - final file = File(filePath); - final exists = await file.exists(); + + bool exists = false; int? size; - - if (exists) { - try { - size = await file.length(); - } catch (_) {} - } - - if (mounted) { + try { + final stat = await FileStat.stat(filePath); + exists = stat.type != FileSystemEntityType.notFound; + if (exists) { + size = stat.size; + } + } catch (_) {} + + if (mounted && (exists != _fileExists || size != _fileSize)) { setState(() { _fileExists = exists; _fileSize = size; }); - - if (exists) { - _fetchLyrics(); - } + } + + if (mounted && exists && _lyrics == null && !_lyricsLoading) { + _fetchLyrics(); } } @@ -80,6 +139,9 @@ class _TrackMetadataScreenState extends ConsumerState { int? get discNumber => item.discNumber; String? get releaseDate => item.releaseDate; String? get isrc => item.isrc; + String? get genre => item.genre; + String? get label => item.label; + String? get copyright => item.copyright; String get cleanFilePath { final path = item.filePath; @@ -91,21 +153,48 @@ class _TrackMetadataScreenState extends ConsumerState { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final screenWidth = MediaQuery.of(context).size.width; + final coverSize = screenWidth * 0.5; + final bgColor = _dominantColor ?? colorScheme.surface; return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ SliverAppBar( - expandedHeight: 280, + expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, - flexibleSpace: FlexibleSpaceBar( - background: _buildHeaderBackground(context, colorScheme), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + backgroundColor: colorScheme.surface, // Use theme color for collapsed state + surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + trackName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.none, + background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent), + stretchModes: const [ + StretchMode.zoomBackground, + StretchMode.blurBackground, + ], + ); + }, ), leading: IconButton( icon: Container( @@ -167,74 +256,73 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) { + Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, Color bgColor, bool showContent) { return Stack( fit: StackFit.expand, children: [ - if (item.coverUrl != null) - CachedNetworkImage( - imageUrl: item.coverUrl!, - fit: BoxFit.cover, - color: Colors.black.withValues(alpha: 0.5), - colorBlendMode: BlendMode.darken, - ), - - Container( + AnimatedContainer( + duration: const Duration(milliseconds: 500), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.transparent, - colorScheme.surface.withValues(alpha: 0.8), + bgColor, + bgColor.withValues(alpha: 0.8), colorScheme.surface, ], - stops: const [0.0, 0.7, 1.0], + stops: const [0.0, 0.6, 1.0], ), ), ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Hero( - tag: 'cover_${item.id}', - 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: item.coverUrl != null - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - fit: BoxFit.cover, - placeholder: (_, _) => Container( + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Hero( + tag: 'cover_${item.id}', + 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: item.coverUrl != null +? CachedNetworkImage( + imageUrl: item.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, - size: 48, + size: 64, color: colorScheme.onSurfaceVariant, ), ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - ), + ), ), ), ), @@ -425,8 +513,14 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { + // Determine audio quality string based on file type String? audioQualityStr; - if (bitDepth != null && sampleRate != null) { + final fileName = item.filePath.split('/').last; + final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : ''; + + if (fileExt == 'MP3') { + audioQualityStr = '320kbps'; + } else if (bitDepth != null && sampleRate != null) { final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; } @@ -447,6 +541,12 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), if (releaseDate != null && releaseDate!.isNotEmpty) _MetadataItem(context.l10n.trackReleaseDate, releaseDate!), + if (genre != null && genre!.isNotEmpty) + _MetadataItem(context.l10n.trackGenre, genre!), + if (label != null && label!.isNotEmpty) + _MetadataItem(context.l10n.trackLabel, label!), + if (copyright != null && copyright!.isNotEmpty) + _MetadataItem(context.l10n.trackCopyright, copyright!), if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!), ]; @@ -578,7 +678,23 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - if (bitDepth != null && sampleRate != null) + if (fileExtension == 'MP3') + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '320kbps', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else if (bitDepth != null && sampleRate != null) Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -811,10 +927,9 @@ class _TrackMetadataScreenState extends ConsumerState { String _cleanLrcForDisplay(String lrc) { final lines = lrc.split('\n'); final cleanLines = []; - final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); for (final line in lines) { - final cleanLine = line.replaceAll(timestampPattern, '').trim(); + final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim(); if (cleanLine.isNotEmpty) { cleanLines.add(cleanLine); } @@ -936,8 +1051,8 @@ class _TrackMetadataScreenState extends ConsumerState { ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); if (context.mounted) { - Navigator.pop(context); // Close dialog - Navigator.pop(context); // Go back to history + Navigator.pop(context); + Navigator.pop(context); } }, child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)), @@ -995,9 +1110,7 @@ class _TrackMetadataScreenState extends ConsumerState { } String _formatFullDate(DateTime date) { - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${date.day} ${months[date.month - 1]} ${date.year}, ' + return '${date.day} ${_months[date.month - 1]} ${date.year}, ' '${date.hour.toString().padLeft(2, '0')}:' '${date.minute.toString().padLeft(2, '0')}'; } diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart new file mode 100644 index 00000000..288dad2f --- /dev/null +++ b/lib/services/cover_cache_manager.dart @@ -0,0 +1,122 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +/// Persistent cache manager for album/track cover images. +/// +/// Unlike the default cache manager which stores in temp directory +/// (can be cleared by system anytime), this stores in app support +/// directory which persists across app restarts. +class CoverCacheManager { + static const String _cacheKey = 'coverImageCache'; + static const int _maxCacheObjects = 1000; + static const Duration _maxCacheAge = Duration(days: 365); + + static CacheManager? _instance; + static bool _initialized = false; + static String? _cachePath; + + static CacheManager get instance { + if (!_initialized || _instance == null) { + // Fallback to default cache manager if not initialized + debugPrint('CoverCacheManager: Not initialized, using DefaultCacheManager'); + return DefaultCacheManager(); + } + return _instance!; + } + + /// Check if cache manager is initialized + static bool get isInitialized => _initialized && _instance != null; + + static Future initialize() async { + if (_initialized) return; + + try { + final appDir = await getApplicationSupportDirectory(); + _cachePath = p.join(appDir.path, 'cover_cache'); + + // Ensure cache directory exists + await Directory(_cachePath!).create(recursive: true); + + debugPrint('CoverCacheManager: Initializing at $_cachePath'); + + _instance = CacheManager( + Config( + _cacheKey, + stalePeriod: _maxCacheAge, + maxNrOfCacheObjects: _maxCacheObjects, + // Use path only (not databaseName) to store database in persistent directory + repo: JsonCacheInfoRepository(path: _cachePath), + fileSystem: IOFileSystem(_cachePath!), + fileService: HttpFileService(), + ), + ); + + _initialized = true; + debugPrint('CoverCacheManager: Initialized successfully'); + } catch (e) { + debugPrint('CoverCacheManager: Failed to initialize: $e'); + // Will fallback to DefaultCacheManager + } + } + + /// Clear all cached cover images. + /// Returns the number of files deleted. + static Future clearCache() async { + if (!_initialized || _instance == null) return; + await _instance!.emptyCache(); + } + + static Future getStats() async { + if (!_initialized || _cachePath == null) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + + final cacheDir = Directory(_cachePath!); + + if (!await cacheDir.exists()) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + + int fileCount = 0; + int totalSize = 0; + + try { + await for (final entity in cacheDir.list(recursive: true)) { + if (entity is File) { + fileCount++; + totalSize += await entity.length(); + } + } + } catch (e) { + debugPrint('CoverCacheManager: Error getting stats: $e'); + } + + return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize); + } +} + +/// Statistics about the cover image cache +class CacheStats { + final int fileCount; + final int totalSizeBytes; + + const CacheStats({ + required this.fileCount, + required this.totalSizeBytes, + }); + + String get formattedSize { + if (totalSizeBytes < 1024) { + return '$totalSizeBytes B'; + } else if (totalSizeBytes < 1024 * 1024) { + return '${(totalSizeBytes / 1024).toStringAsFixed(1)} KB'; + } else if (totalSizeBytes < 1024 * 1024 * 1024) { + return '${(totalSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } else { + return '${(totalSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + } +} diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index b2ef0a1c..0687f385 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -7,8 +7,6 @@ import 'package:spotiflac_android/utils/logger.dart'; class CsvImportService { static final _log = AppLogger('CsvImportService'); - /// Pick and parse CSV file, then enrich metadata from Deezer - /// [onProgress] callback receives (current, total) for progress updates static Future> pickAndParseCsv({ void Function(int current, int total)? onProgress, }) async { @@ -34,8 +32,6 @@ class CsvImportService { return []; } - /// Enrich tracks with metadata from Deezer using ISRC or search - /// This fetches cover URL, duration, and other metadata that CSV doesn't have static Future> _enrichTracksMetadata( List tracks, { void Function(int current, int total)? onProgress, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index d1fdbc55..6243d41e 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -10,7 +10,6 @@ final _log = AppLogger('FFmpeg'); class FFmpegService { static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg'); - /// Execute FFmpeg command and return result static Future _execute(String command) async { try { final result = await _channel.invokeMethod('execute', {'command': command}); @@ -26,8 +25,6 @@ class FFmpegService { } } - /// Convert M4A (DASH segments) to FLAC - /// Returns the output file path on success, null on failure static Future convertM4aToFlac(String inputPath) async { final outputPath = inputPath.replaceAll('.m4a', '.flac'); @@ -47,19 +44,12 @@ class FFmpegService { return null; } - /// Convert FLAC to MP3 static Future convertFlacToMp3( String inputPath, { String bitrate = '320k', + bool deleteOriginal = true, }) 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'; + 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'; @@ -67,6 +57,11 @@ class FFmpegService { final result = await _execute(command); if (result.success) { + if (deleteOriginal) { + try { + await File(inputPath).delete(); + } catch (_) {} + } return outputPath; } @@ -74,7 +69,6 @@ class FFmpegService { return null; } - /// Convert FLAC to M4A (AAC or ALAC) static Future convertFlacToM4a( String inputPath, { String codec = 'aac', @@ -108,7 +102,6 @@ class FFmpegService { return null; } - /// Check if FFmpeg is available static Future isAvailable() async { try { final version = await _channel.invokeMethod('getVersion'); @@ -118,7 +111,6 @@ class FFmpegService { } } - /// Get FFmpeg version info static Future getVersion() async { try { final version = await _channel.invokeMethod('getVersion'); @@ -128,8 +120,6 @@ class FFmpegService { } } - /// Embed metadata and cover art to FLAC file - /// Returns the file path on success, null on failure static Future embedMetadata({ required String flacPath, String? coverPath, @@ -201,14 +191,145 @@ class FFmpegService { if (await tempFile.exists()) { await tempFile.delete(); } - } catch (_) {} + } catch (e) { + _log.w('Failed to cleanup temp file: $e'); + } _log.e('Metadata/Cover embed failed: ${result.output}'); return null; } + + static Future embedMetadataToMp3({ + required String mp3Path, + String? coverPath, + Map? metadata, + }) async { + final tempDir = await getTemporaryDirectory(); + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3'; + + 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) { + 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 { + final tempFile = File(tempOutput); + final originalFile = File(mp3Path); + + if (await tempFile.exists()) { + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(mp3Path); + await tempFile.delete(); + + _log.d('MP3 metadata embedded successfully'); + return mp3Path; + } else { + _log.e('Temp MP3 output file not found: $tempOutput'); + return null; + } + + } 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 (e) { + _log.w('Failed to cleanup temp MP3 file: $e'); + } + + _log.e('MP3 Metadata/Cover embed failed: ${result.output}'); + return null; + } + + static Map _convertToId3Tags(Map vorbisMetadata) { + final id3Map = {}; + + 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; + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + id3Map['lyrics'] = value; + break; + default: + // Pass through other tags as-is + id3Map[key.toLowerCase()] = value; + } + } + + return id3Map; + } } -/// Result of FFmpeg command execution class FFmpegResult { final bool success; final int returnCode; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 3df93bfc..f0895bcd 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -4,25 +4,21 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('PlatformBridge'); -/// Bridge to communicate with Go backend via platform channels class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); - /// Parse and validate Spotify URL static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); return jsonDecode(result as String) as Map; } - /// Get Spotify metadata from URL static Future> getSpotifyMetadata(String url) async { _log.d('getSpotifyMetadata: $url'); final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); return jsonDecode(result as String) as Map; } - /// Search Spotify static Future> searchSpotify(String query, {int limit = 10}) async { _log.d('searchSpotify: "$query" (limit: $limit)'); final result = await _channel.invokeMethod('searchSpotify', { @@ -32,7 +28,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Search Spotify for tracks and artists static Future> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { _log.d('searchSpotifyAll: "$query"'); final result = await _channel.invokeMethod('searchSpotifyAll', { @@ -43,7 +38,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Check track availability on streaming services static Future> checkAvailability(String spotifyId, String isrc) async { _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); final result = await _channel.invokeMethod('checkAvailability', { @@ -53,7 +47,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Download a track from specific service static Future> downloadTrack({ required String isrc, required String service, @@ -108,7 +101,6 @@ class PlatformBridge { return response; } - /// Download with automatic fallback to other services static Future> downloadWithFallback({ required String isrc, required String spotifyId, @@ -129,6 +121,10 @@ class PlatformBridge { String preferredService = 'tidal', String? itemId, int durationMs = 0, + String? genre, + String? label, + String? copyright, + String lyricsMode = 'embed', }) async { _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); final request = jsonEncode({ @@ -151,6 +147,10 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, + 'genre': genre ?? '', + 'label': label ?? '', + 'copyright': copyright ?? '', + 'lyrics_mode': lyricsMode, }); final result = await _channel.invokeMethod('downloadWithFallback', request); @@ -172,44 +172,36 @@ class PlatformBridge { return response; } - /// Get download progress (legacy single download) static Future> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); return jsonDecode(result as String) as Map; } - /// Get progress for all active downloads (concurrent mode) static Future> getAllDownloadProgress() async { final result = await _channel.invokeMethod('getAllDownloadProgress'); return jsonDecode(result as String) as Map; } - /// Initialize progress tracking for a download item static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } - /// Finish progress tracking for a download item static Future finishItemProgress(String itemId) async { await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); } - /// Clear progress tracking for a download item static Future clearItemProgress(String itemId) async { await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); } - /// Cancel an in-progress download static Future cancelDownload(String itemId) async { await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); } - /// Set download directory static Future setDownloadDirectory(String path) async { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } - /// Check if file with ISRC already exists static Future> checkDuplicate(String outputDir, String isrc) async { final result = await _channel.invokeMethod('checkDuplicate', { 'output_dir': outputDir, @@ -218,7 +210,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Build filename from template static Future buildFilename(String template, Map metadata) async { final result = await _channel.invokeMethod('buildFilename', { 'template': template, @@ -227,7 +218,6 @@ class PlatformBridge { return result as String; } - /// Sanitize filename static Future sanitizeFilename(String filename) async { final result = await _channel.invokeMethod('sanitizeFilename', { 'filename': filename, @@ -235,8 +225,6 @@ class PlatformBridge { return result as String; } - /// Fetch lyrics for a track - /// [durationMs] is the track duration in milliseconds for better matching static Future> fetchLyrics( String spotifyId, String trackName, @@ -252,9 +240,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get lyrics in LRC format - /// First tries to extract from embedded file, then falls back to internet - /// [durationMs] is the track duration in milliseconds for better matching static Future getLyricsLRC( String spotifyId, String trackName, @@ -272,7 +257,6 @@ class PlatformBridge { return result as String; } - /// Embed lyrics into an existing FLAC file static Future> embedLyricsToFile( String filePath, String lyrics, @@ -284,15 +268,10 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Cleanup idle HTTP connections to prevent TCP exhaustion - /// Call this periodically during large batch downloads static Future cleanupConnections() async { await _channel.invokeMethod('cleanupConnections'); } - /// Read metadata directly from a FLAC file - /// Returns all embedded metadata (title, artist, album, track number, etc.) - /// This reads from the actual file, not from cached/database data static Future> readFileMetadata(String filePath) async { final result = await _channel.invokeMethod('readFileMetadata', { 'file_path': filePath, @@ -300,7 +279,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Start foreground download service to keep downloads running in background static Future startDownloadService({ String trackName = '', String artistName = '', @@ -313,12 +291,10 @@ class PlatformBridge { }); } - /// Stop foreground download service static Future stopDownloadService() async { await _channel.invokeMethod('stopDownloadService'); } - /// Update download service notification progress static Future updateDownloadServiceProgress({ required String trackName, required String artistName, @@ -335,13 +311,11 @@ class PlatformBridge { }); } - /// Check if download service is running static Future isDownloadServiceRunning() async { final result = await _channel.invokeMethod('isDownloadServiceRunning'); return result as bool; } - /// Set custom Spotify API credentials static Future setSpotifyCredentials(String clientId, String clientSecret) async { await _channel.invokeMethod('setSpotifyCredentials', { 'client_id': clientId, @@ -349,35 +323,26 @@ class PlatformBridge { }); } - /// Check if Spotify credentials are configured /// Returns true if credentials are available (custom or env vars) static Future hasSpotifyCredentials() async { final result = await _channel.invokeMethod('hasSpotifyCredentials'); return result as bool; } - /// Pre-warm track ID cache for album/playlist tracks - /// This runs in background and returns immediately - /// Speeds up subsequent downloads by caching ISRC → Track ID mappings static Future preWarmTrackCache(List> tracks) async { final tracksJson = jsonEncode(tracks); await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); } - /// Get current track cache size static Future getTrackCacheSize() async { final result = await _channel.invokeMethod('getTrackCacheSize'); return result as int; } - /// Clear track ID cache static Future clearTrackCache() async { await _channel.invokeMethod('clearTrackCache'); } - // ==================== DEEZER API ==================== - - /// Search Deezer for tracks and artists (no API key required) static Future> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { final result = await _channel.invokeMethod('searchDeezerAll', { 'query': query, @@ -387,7 +352,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get Deezer metadata by type and ID static Future> getDeezerMetadata(String resourceType, String resourceId) async { final result = await _channel.invokeMethod('getDeezerMetadata', { 'resource_type': resourceType, @@ -399,19 +363,33 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Parse Deezer URL and return type and ID static Future> parseDeezerUrl(String url) async { final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); return jsonDecode(result as String) as Map; } - /// Search Deezer by ISRC static Future> searchDeezerByISRC(String isrc) async { final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); return jsonDecode(result as String) as Map; } - /// Convert Spotify track to Deezer and get metadata (for rate limit fallback) + static Future?> getDeezerExtendedMetadata(String trackId) async { + try { + final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { + 'track_id': trackId, + }); + if (result == null) return null; + final data = jsonDecode(result as String) as Map; + return { + 'genre': data['genre'] as String? ?? '', + 'label': data['label'] as String? ?? '', + }; + } catch (e) { + _log.w('Failed to get Deezer extended metadata for $trackId: $e'); + return null; + } + } + static Future> convertSpotifyToDeezer(String resourceType, String spotifyId) async { final result = await _channel.invokeMethod('convertSpotifyToDeezer', { 'resource_type': resourceType, @@ -420,15 +398,11 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get Spotify metadata with automatic Deezer fallback on rate limit static Future> getSpotifyMetadataWithFallback(String url) async { final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); return jsonDecode(result as String) as Map; } - // ==================== GO BACKEND LOGS ==================== - - /// Get all logs from Go backend static Future>> getGoLogs() async { final result = await _channel.invokeMethod('getLogs'); final logs = jsonDecode(result as String) as List; @@ -441,25 +415,20 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Clear Go backend logs static Future clearGoLogs() async { await _channel.invokeMethod('clearLogs'); } - /// Get Go backend log count static Future getGoLogCount() async { final result = await _channel.invokeMethod('getLogCount'); return result as int; } - /// Enable or disable Go backend logging static Future setGoLoggingEnabled(bool enabled) async { await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); } - // ==================== EXTENSION SYSTEM ==================== - /// Initialize the extension system static Future initExtensionSystem(String extensionsDir, String dataDir) async { _log.d('initExtensionSystem: $extensionsDir, $dataDir'); await _channel.invokeMethod('initExtensionSystem', { @@ -468,7 +437,6 @@ class PlatformBridge { }); } - /// Load all extensions from directory static Future> loadExtensionsFromDir(String dirPath) async { _log.d('loadExtensionsFromDir: $dirPath'); final result = await _channel.invokeMethod('loadExtensionsFromDir', { @@ -477,7 +445,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Load a single extension from file static Future> loadExtensionFromPath(String filePath) async { _log.d('loadExtensionFromPath: $filePath'); final result = await _channel.invokeMethod('loadExtensionFromPath', { @@ -486,7 +453,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Unload an extension static Future unloadExtension(String extensionId) async { _log.d('unloadExtension: $extensionId'); await _channel.invokeMethod('unloadExtension', { @@ -494,7 +460,6 @@ class PlatformBridge { }); } - /// Remove an extension completely (unload + delete files) static Future removeExtension(String extensionId) async { _log.d('removeExtension: $extensionId'); await _channel.invokeMethod('removeExtension', { @@ -502,7 +467,6 @@ class PlatformBridge { }); } - /// Upgrade an existing extension from a new package file static Future> upgradeExtension(String filePath) async { _log.d('upgradeExtension: $filePath'); final result = await _channel.invokeMethod('upgradeExtension', { @@ -511,7 +475,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Check if a package file is an upgrade for an existing extension static Future> checkExtensionUpgrade(String filePath) async { _log.d('checkExtensionUpgrade: $filePath'); final result = await _channel.invokeMethod('checkExtensionUpgrade', { @@ -520,14 +483,12 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get all installed extensions static Future>> getInstalledExtensions() async { final result = await _channel.invokeMethod('getInstalledExtensions'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - /// Enable or disable an extension static Future setExtensionEnabled(String extensionId, bool enabled) async { _log.d('setExtensionEnabled: $extensionId = $enabled'); await _channel.invokeMethod('setExtensionEnabled', { @@ -536,7 +497,6 @@ class PlatformBridge { }); } - /// Set provider priority order static Future setProviderPriority(List providerIds) async { _log.d('setProviderPriority: $providerIds'); await _channel.invokeMethod('setProviderPriority', { @@ -544,14 +504,12 @@ class PlatformBridge { }); } - /// Get provider priority order static Future> getProviderPriority() async { final result = await _channel.invokeMethod('getProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } - /// Set metadata provider priority order static Future setMetadataProviderPriority(List providerIds) async { _log.d('setMetadataProviderPriority: $providerIds'); await _channel.invokeMethod('setMetadataProviderPriority', { @@ -559,14 +517,12 @@ class PlatformBridge { }); } - /// Get metadata provider priority order static Future> getMetadataProviderPriority() async { final result = await _channel.invokeMethod('getMetadataProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } - /// Get extension settings static Future> getExtensionSettings(String extensionId) async { final result = await _channel.invokeMethod('getExtensionSettings', { 'extension_id': extensionId, @@ -574,7 +530,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set extension settings static Future setExtensionSettings(String extensionId, Map settings) async { _log.d('setExtensionSettings: $extensionId'); await _channel.invokeMethod('setExtensionSettings', { @@ -583,7 +538,18 @@ class PlatformBridge { }); } - /// Search tracks using extension providers + static Future> invokeExtensionAction(String extensionId, String actionName) async { + _log.d('invokeExtensionAction: $extensionId.$actionName'); + final result = await _channel.invokeMethod('invokeExtensionAction', { + 'extension_id': extensionId, + 'action': actionName, + }); + if (result == null || (result as String).isEmpty) { + return {'success': true}; + } + return jsonDecode(result) as Map; + } + static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { _log.d('searchTracksWithExtensions: "$query"'); final result = await _channel.invokeMethod('searchTracksWithExtensions', { @@ -594,7 +560,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Download with extension providers (includes fallback) static Future> downloadWithExtensions({ required String isrc, required String spotifyId, @@ -614,7 +579,10 @@ class PlatformBridge { String? releaseDate, String? itemId, int durationMs = 0, - String? source, // Extension ID that provided this track (prioritize this extension) + String? source, + String? genre, + String? label, + String lyricsMode = 'embed', }) async { _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); final request = jsonEncode({ @@ -636,22 +604,21 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, - 'source': source ?? '', // Extension ID that provided this track + 'source': source ?? '', + 'genre': genre ?? '', + 'label': label ?? '', + 'lyrics_mode': lyricsMode, }); final result = await _channel.invokeMethod('downloadWithExtensions', request); return jsonDecode(result as String) as Map; } - /// Cleanup all extensions (call on app close) static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions'); } - // ==================== EXTENSION AUTH API ==================== - - /// Get pending auth request for an extension (if any) static Future?> getExtensionPendingAuth(String extensionId) async { final result = await _channel.invokeMethod('getExtensionPendingAuth', { 'extension_id': extensionId, @@ -660,7 +627,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set auth code for an extension (after OAuth callback) static Future setExtensionAuthCode(String extensionId, String authCode) async { _log.d('setExtensionAuthCode: $extensionId'); await _channel.invokeMethod('setExtensionAuthCode', { @@ -669,7 +635,6 @@ class PlatformBridge { }); } - /// Set tokens for an extension (after token exchange) static Future setExtensionTokens( String extensionId, { required String accessToken, @@ -685,14 +650,12 @@ class PlatformBridge { }); } - /// Clear pending auth request for an extension static Future clearExtensionPendingAuth(String extensionId) async { await _channel.invokeMethod('clearExtensionPendingAuth', { 'extension_id': extensionId, }); } - /// Check if extension is authenticated static Future isExtensionAuthenticated(String extensionId) async { final result = await _channel.invokeMethod('isExtensionAuthenticated', { 'extension_id': extensionId, @@ -700,16 +663,12 @@ class PlatformBridge { return result as bool; } - /// Get all pending auth requests (for polling) static Future>> getAllPendingAuthRequests() async { final result = await _channel.invokeMethod('getAllPendingAuthRequests'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION FFMPEG API ==================== - - /// Get pending FFmpeg command for execution static Future?> getPendingFFmpegCommand(String commandId) async { final result = await _channel.invokeMethod('getPendingFFmpegCommand', { 'command_id': commandId, @@ -718,7 +677,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set FFmpeg command result static Future setFFmpegCommandResult( String commandId, { required bool success, @@ -733,16 +691,12 @@ class PlatformBridge { }); } - /// Get all pending FFmpeg commands static Future>> getAllPendingFFmpegCommands() async { final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION CUSTOM SEARCH ==================== - - /// Perform custom search using an extension static Future>> customSearchWithExtension( String extensionId, String query, { @@ -757,17 +711,12 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Get all extensions that provide custom search static Future>> getSearchProviders() async { final result = await _channel.invokeMethod('getSearchProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION URL HANDLER ==================== - - /// Handle a URL with any matching extension - /// Returns null if no extension can handle the URL static Future?> handleURLWithExtension(String url) async { try { final result = await _channel.invokeMethod('handleURLWithExtension', { @@ -780,8 +729,6 @@ class PlatformBridge { } } - /// Find an extension that can handle the given URL - /// Returns extension ID or null if none found static Future findURLHandler(String url) async { final result = await _channel.invokeMethod('findURLHandler', { 'url': url, @@ -790,14 +737,12 @@ class PlatformBridge { return result as String; } - /// Get all extensions that handle custom URLs static Future>> getURLHandlers() async { final result = await _channel.invokeMethod('getURLHandlers'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - /// Get album tracks using an extension static Future?> getAlbumWithExtension( String extensionId, String albumId, @@ -815,7 +760,6 @@ class PlatformBridge { } } - /// Get playlist tracks using an extension static Future?> getPlaylistWithExtension( String extensionId, String playlistId, @@ -833,7 +777,6 @@ class PlatformBridge { } } - /// Get artist info and albums using an extension static Future?> getArtistWithExtension( String extensionId, String artistId, @@ -851,9 +794,7 @@ class PlatformBridge { } } - // ==================== EXTENSION POST-PROCESSING ==================== - /// Run post-processing hooks on a file static Future> runPostProcessing( String filePath, { Map? metadata, @@ -865,22 +806,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get all extensions that provide post-processing static Future>> getPostProcessingProviders() async { final result = await _channel.invokeMethod('getPostProcessingProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION STORE ==================== - /// Initialize extension store static Future initExtensionStore(String cacheDir) async { _log.d('initExtensionStore: $cacheDir'); await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); } - /// Get all extensions from store with installation status static Future>> getStoreExtensions({bool forceRefresh = false}) async { _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); final result = await _channel.invokeMethod('getStoreExtensions', { @@ -890,7 +827,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Search extensions in store static Future>> searchStoreExtensions(String query, {String? category}) async { _log.d('searchStoreExtensions: "$query" (category: $category)'); final result = await _channel.invokeMethod('searchStoreExtensions', { @@ -901,14 +837,12 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Get store categories static Future> getStoreCategories() async { final result = await _channel.invokeMethod('getStoreCategories'); final list = jsonDecode(result as String) as List; return list.cast(); } - /// Download extension from store static Future downloadStoreExtension(String extensionId, String destDir) async { _log.i('downloadStoreExtension: $extensionId to $destDir'); final result = await _channel.invokeMethod('downloadStoreExtension', { @@ -918,7 +852,6 @@ class PlatformBridge { return result as String; } - /// Clear store cache static Future clearStoreCache() async { _log.d('clearStoreCache'); await _channel.invokeMethod('clearStoreCache'); diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 257e057c..36b032ec 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -4,7 +4,6 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('ShareIntent'); -/// Service to handle incoming share intents from other apps (e.g., Spotify) class ShareIntentService { static final ShareIntentService _instance = ShareIntentService._internal(); factory ShareIntentService() => _instance; @@ -15,17 +14,14 @@ class ShareIntentService { bool _initialized = false; String? _pendingUrl; // Store URL received before listener is ready - /// Stream of shared Spotify URLs Stream get sharedUrlStream => _sharedUrlController.stream; - /// Get pending URL that was received before listener was ready String? consumePendingUrl() { final url = _pendingUrl; _pendingUrl = null; return url; } - /// Initialize the service and start listening for share intents Future initialize() async { if (_initialized) return; _initialized = true; @@ -58,11 +54,6 @@ class ShareIntentService { } } - /// Extract Spotify URL from shared text - /// Handles various formats: - /// - Direct URL: https://open.spotify.com/track/xxx - /// - With text: "Check out this song! https://open.spotify.com/track/xxx" - /// - Spotify URI: spotify:track:xxx String? _extractSpotifyUrl(String text) { if (text.isEmpty) return null; @@ -83,7 +74,6 @@ class ShareIntentService { return null; } - /// Dispose resources void dispose() { _mediaSubscription?.cancel(); _sharedUrlController.close(); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 619abf83..2aba9e85 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; -/// App theme configuration for Material Expressive 3 class AppTheme { /// Default seed color (Spotify green) static const Color defaultSeedColor = Color(kDefaultSeedColor); - /// Create light theme static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) { final scheme = dynamicScheme ?? @@ -73,7 +71,6 @@ class AppTheme { ); } - /// AppBar theme static AppBarTheme _appBarTheme( ColorScheme scheme, { bool isAmoled = false, @@ -101,7 +98,6 @@ class AppTheme { surfaceTintColor: scheme.surfaceTint, ); - /// Elevated button theme static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) => ElevatedButtonThemeData( style: ElevatedButton.styleFrom( @@ -124,7 +120,6 @@ class AppTheme { ), ); - /// Outlined button theme static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) => OutlinedButtonThemeData( style: OutlinedButton.styleFrom( @@ -146,7 +141,6 @@ class AppTheme { ), ); - /// FAB theme static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) => FloatingActionButtonThemeData( elevation: 3, @@ -184,7 +178,6 @@ class AppTheme { ), // consistent padding ); - /// List tile theme static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( shape: RoundedRectangleBorder( @@ -193,7 +186,6 @@ class AppTheme { contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); - /// Dialog theme static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData( elevation: 6, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), @@ -213,7 +205,6 @@ class AppTheme { labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, ); - /// SnackBar theme static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData( behavior: SnackBarBehavior.floating, @@ -231,7 +222,6 @@ class AppTheme { circularTrackColor: scheme.surfaceContainerHighest, ); - /// Switch theme static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData( thumbColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { @@ -260,7 +250,6 @@ class AppTheme { selectedColor: scheme.secondaryContainer, ); - /// Divider theme static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1); } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index bf6f0bec..3e69757b 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -/// Log entry with timestamp and level class LogEntry { final DateTime timestamp; final String level; @@ -38,7 +37,6 @@ class LogEntry { } } -/// Circular buffer for storing logs in memory class LogBuffer extends ChangeNotifier { static final LogBuffer _instance = LogBuffer._internal(); factory LogBuffer() => _instance; @@ -134,7 +132,6 @@ class LogBuffer extends ChangeNotifier { _lastGoLogIndex = nextIndex; } catch (e) { - // Ignore errors - Go backend might not be ready if (kDebugMode) { debugPrint('Failed to fetch Go logs: $e'); } @@ -180,7 +177,6 @@ class LogBuffer extends ChangeNotifier { } } -/// Custom log output that writes to both console and buffer class BufferedOutput extends LogOutput { final String tag; @@ -236,9 +232,6 @@ final log = Logger( level: Level.debug, ); -/// Logger with class/tag prefix for better traceability -/// Now also writes to LogBuffer for in-app viewing -/// Works in both debug and release mode class AppLogger { final String _tag; late final Logger? _logger; diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart new file mode 100644 index 00000000..a983d818 --- /dev/null +++ b/lib/widgets/cached_cover_image.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; + +class CachedCoverImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final int? memCacheWidth; + final int? memCacheHeight; + final Widget Function(BuildContext, String, Object)? errorWidget; + final Widget Function(BuildContext, String)? placeholder; + final BorderRadius? borderRadius; + + const CachedCoverImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.memCacheWidth, + this.memCacheHeight, + this.errorWidget, + this.placeholder, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final image = CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance + : null, + errorWidget: errorWidget, + placeholder: placeholder, + ); + + if (borderRadius != null) { + return ClipRRect( + borderRadius: borderRadius!, + child: image, + ); + } + + return image; + } +} + +CachedNetworkImageProvider cachedCoverImageProvider(String url) { + return CachedNetworkImageProvider( + url, + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance + : null, + ); +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index c2748dbf..962a807d 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -49,6 +49,13 @@ const _builtInServices = [ ), ]; +/// MP3 quality option (shown when enabled in settings) +const _mp3QualityOption = QualityOption( + id: 'MP3', + label: 'MP3', + description: '320kbps (converted from FLAC)', +); + /// A reusable widget for selecting download service (built-in + extensions) class DownloadServicePicker extends ConsumerStatefulWidget { final String? trackName; @@ -105,20 +112,34 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { + final settings = ref.read(settingsProvider); final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; if (builtIn != null) { + // Add MP3 option if enabled in settings + if (settings.enableMp3Option) { + return [...builtIn.qualityOptions, _mp3QualityOption]; + } return builtIn.qualityOptions; } final extensionState = ref.read(extensionProvider); final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; if (ext != null && ext.qualityOptions.isNotEmpty) { + // Add MP3 option for extensions too if enabled + if (settings.enableMp3Option) { + return [...ext.qualityOptions, _mp3QualityOption]; + } return ext.qualityOptions; } - return const [ - QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), + // Default fallback options + final defaultOptions = [ + const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), ]; + if (settings.enableMp3Option) { + return [...defaultOptions, _mp3QualityOption]; + } + return defaultOptions; } @override diff --git a/pubspec.lock b/pubspec.lock index dbc3add7..5233bceb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -327,7 +327,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -653,8 +653,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" + url: "https://pub.dev" + source: hosted + version: "0.3.3+7" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index b820511f..ced54854 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.1.1+60 +version: 3.1.2+61 environment: sdk: ^3.10.0 @@ -22,9 +22,10 @@ dependencies: # Navigation go_router: ^17.0.1 - # Storage & Persistence +# Storage & Persistence shared_preferences: ^2.5.3 path_provider: ^2.1.5 + path: ^1.9.0 # HTTP & Network http: ^1.6.0 @@ -33,11 +34,13 @@ dependencies: # UI Components cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color dynamic_color: ^1.7.0 material_color_utilities: ^0.11.1 + palette_generator: ^0.3.3+4 # Permissions permission_handler: ^12.0.1 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index fe320546..94fd2e1b 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.1.1+60 +version: 3.1.2+61 environment: sdk: ^3.10.0 @@ -22,22 +22,25 @@ dependencies: # Navigation go_router: ^17.0.1 - # Storage & Persistence +# Storage & Persistence shared_preferences: ^2.5.3 path_provider: ^2.1.5 + path: ^1.9.0 # HTTP & Network http: ^1.6.0 dio: ^5.8.0 - # UI Components +# UI Components cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color dynamic_color: ^1.7.0 material_color_utilities: ^0.11.1 + palette_generator: ^0.3.3+4 # Permissions permission_handler: ^12.0.1