Compare commits

..

47 Commits

Author SHA1 Message Date
zarzet be9444c76b fix: revert material_color_utilities to ^0.11.1 (pinned by Flutter SDK) 2026-01-17 05:41:33 +07:00
zarzet cedb32904e fix(ios): add localization support for iOS build
- Add flutter_localizations and intl to pubspec_ios.yaml

- Add generate: true flag for l10n code generation

- Add CFBundleLocalizations to Info.plist with all supported languages

- Update http to ^1.6.0 and material_color_utilities to ^0.13.0

- Update file_picker to ^10.3.8
2026-01-17 05:20:04 +07:00
zarzet e73f932083 fix: update Crowdin config for Chinese locales and add missing l10n keys
- Change crowdin.yml to use %locale_with_underscore% for proper zh_CN/zh_TW handling
- Add sectionLanguage, appearanceLanguage, appearanceLanguageSubtitle to app_en.arb
- Add app_zh_CN.arb for Simplified Chinese (Crowdin target)
- Update .gitignore to exclude log files and tool/ folder
- Regenerate localization dart files
2026-01-17 05:02:57 +07:00
zarzet 4645d3ac8b fix: correct @@locale values to match filenames (es-ES→es, pt-PT→pt, zh-TW→zh) 2026-01-17 04:51:21 +07:00
Zarz Eleutherius 1cdf8b7f23 Merge pull request #57 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 04:34:21 +07:00
zarzet 1e18f53e6a Merge dev into l10n_dev - resolve conflicts by keeping dev version 2026-01-17 04:33:34 +07:00
zarzet fc8cfb05d0 feat: add recent access history, artist screen redesign, and extension improvements
Recent Access History:
- Quick access to recently visited artists, albums, playlists, and tracks
- Tap search bar to show recent access list
- Stays visible after keyboard dismiss, exit with back button
- Persists across app restarts (SharedPreferences)
- X button to remove items, Clear All button for all

Artist Screen Redesign:
- Full-width header image with gradient overlay
- Monthly listeners display with compact notation
- Popular section with top 5 tracks and download status
- Extension artists skip Spotify/Deezer fetch (no rate limit errors)

Go Backend:
- GetArtistWithExtensionJSON now returns top_tracks, header_image, listeners

Bug Fixes:
- Search bar unfocus when tapping outside
- Keyboard not appearing on Settings navigation return
- Recent access artist navigation uses correct screen for extensions
- Extension artist screen correctly parses and forwards top tracks

Localization:
- Added recentPlaylistInfo, errorGeneric strings
- Multi-language support via Crowdin

Extensions:
- YT Music: v1.5.0 (top_tracks in getArtist)
- Spotify Web: v1.6.0
2026-01-17 04:29:39 +07:00
Zarz Eleutherius fc0c0571fe New translations app_en.arb (Hindi) 2026-01-16 07:24:21 +07:00
Zarz Eleutherius e6ca29e199 New translations app_en.arb (Indonesian) 2026-01-16 07:24:20 +07:00
Zarz Eleutherius 7413a8a698 New translations app_en.arb (Chinese Traditional) 2026-01-16 07:24:19 +07:00
Zarz Eleutherius 205032e094 New translations app_en.arb (Chinese Simplified) 2026-01-16 07:24:18 +07:00
Zarz Eleutherius 9c6f438e22 New translations app_en.arb (Russian) 2026-01-16 07:24:17 +07:00
Zarz Eleutherius 4f2587554a New translations app_en.arb (Portuguese) 2026-01-16 07:24:16 +07:00
Zarz Eleutherius 369fdd84bf New translations app_en.arb (Dutch) 2026-01-16 07:24:15 +07:00
Zarz Eleutherius 5c3b668e92 New translations app_en.arb (Korean) 2026-01-16 07:24:14 +07:00
Zarz Eleutherius 141db45051 New translations app_en.arb (Japanese) 2026-01-16 07:24:13 +07:00
Zarz Eleutherius 8f9bc8f058 New translations app_en.arb (German) 2026-01-16 07:24:11 +07:00
Zarz Eleutherius be372604fe New translations app_en.arb (Spanish) 2026-01-16 07:24:10 +07:00
Zarz Eleutherius 6c25fc6a8d New translations app_en.arb (French) 2026-01-16 07:24:09 +07:00
Zarz Eleutherius 2eef021587 Update source file app_en.arb 2026-01-16 07:24:03 +07:00
zarzet 9eac6e6e56 docs: add Crowdin translation badge to README 2026-01-16 07:07:46 +07:00
zarzet e5c310f455 fix: update crowdin.yml to use two_letters_code for locale format
- Change from %locale% (id-ID) to %two_letters_code% (id)
- Matches Flutter l10n expected filename format
2026-01-16 06:39:16 +07:00
zarzet d8f73dfa56 feat: add support for 13 languages with improved language selector
- Rename Crowdin ARB files from locale-REGION to locale format
- Fix @@locale values to match filenames
- Update language selector to bottom sheet picker (supports 13 languages)
- Supported: English, Indonesian, German, Spanish, French, Hindi,
  Japanese, Korean, Dutch, Portuguese, Russian, Chinese (Simplified/Traditional)
- Remove duplicate app_id-ID.arb (keep app_id.arb)
2026-01-16 06:38:52 +07:00
zarzet f128d0caf0 Merge branch 'dev' of https://github.com/zarzet/SpotiFLAC-Mobile into dev 2026-01-16 06:35:44 +07:00
zarzet aa499ceba2 refactor: use consistent ViewModeChip for language selector
- Remove duplicate _LanguageChip widget
- Reuse _ViewModeChip for Material Design 3 consistency
- Same font size (12), padding, and styling as other selectors
2026-01-16 06:28:26 +07:00
zarzet 01306afc2d feat: add language selector in Appearance settings
- Add locale field to AppSettings model with 'system' default
- Add Language section in Appearance settings page
- Support System Default, English, and Indonesian options
- App language changes take effect immediately
- Add localization strings for language selector
2026-01-16 06:25:24 +07:00
Zarz Eleutherius 9a3cd0273b Merge pull request #56 from zarzet/l10n_dev
New Crowdin updates
2026-01-16 06:25:02 +07:00
Zarz Eleutherius ac25683f33 New translations app_en.arb (Hindi) 2026-01-16 06:22:51 +07:00
Zarz Eleutherius 624b2112d8 New translations app_en.arb (Indonesian) 2026-01-16 06:22:50 +07:00
Zarz Eleutherius 8bd34dc87e New translations app_en.arb (Chinese Traditional) 2026-01-16 06:22:49 +07:00
Zarz Eleutherius 948779bcfc New translations app_en.arb (Chinese Simplified) 2026-01-16 06:22:48 +07:00
Zarz Eleutherius a74b3a19f7 New translations app_en.arb (Russian) 2026-01-16 06:22:47 +07:00
Zarz Eleutherius 931d9fbf61 New translations app_en.arb (Portuguese) 2026-01-16 06:22:46 +07:00
Zarz Eleutherius a8c76004db New translations app_en.arb (Dutch) 2026-01-16 06:22:45 +07:00
Zarz Eleutherius 0df4596f79 New translations app_en.arb (Korean) 2026-01-16 06:22:44 +07:00
Zarz Eleutherius cf549df049 New translations app_en.arb (Japanese) 2026-01-16 06:22:43 +07:00
Zarz Eleutherius bd3783154b New translations app_en.arb (German) 2026-01-16 06:22:42 +07:00
Zarz Eleutherius 6919408905 New translations app_en.arb (Spanish) 2026-01-16 06:22:41 +07:00
Zarz Eleutherius f4c08a5981 New translations app_en.arb (French) 2026-01-16 06:22:39 +07:00
zarzet 7fff55da96 chore: add crowdin.yml for translation sync 2026-01-16 06:16:28 +07:00
zarzet 3c4dbd1a80 docs: add @key metadata descriptions for Crowdin translators
- Add description field to all 400+ localization strings
- Mark brand names and technical terms as DO NOT TRANSLATE
- Add placeholder descriptions for parameterized strings
- Helps translators understand context for each string
2026-01-16 05:58:36 +07:00
zarzet f26af38c1e feat: add multilanguage support (i18n) for English and Indonesian
- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration and ARB files (app_en.arb, app_id.arb)
- Add L10n extension for easy context.l10n access
- Localize all active screens:
  - setup_screen, track_metadata_screen, log_screen
  - download_settings_page, options_settings_page, appearance_settings_page
  - extensions_page, extension_detail_page, extension_details_screen
  - about_page, provider_priority_page, metadata_provider_priority_page
  - home_tab, queue_tab, store_tab, main_shell
  - album_screen, artist_screen, playlist_screen
  - downloaded_album_screen, queue_screen
- Localize widgets: update_dialog, download_service_picker
- Technical terms (FLAC, API, Spotify, Tidal, Qobuz, etc.) are NOT translated
- ~900+ localized strings in English, ~660+ in Indonesian
2026-01-16 05:50:11 +07:00
zarzet 7c6705c75c merge: main into dev - resolved conflicts for mime utils, version, gitignore 2026-01-16 03:47:45 +07:00
zarzet b193bc0b8f feat: download cancellation, duplicate detection, progress tracking improvements 2026-01-16 03:46:31 +07:00
zarzet 1a90887465 release: v3.1.0 - fix Separate Singles, extension metadata, YTMusic parsing
Fixes:
- Fix Separate Singles not working (#54) - albumType not extracted from Deezer API
- Fix extension artist/album metadata missing provider IDs and cover URLs
- Fix YTMusic extension not extracting album name and duration from search
- Fix extension collection screens setState after dispose
- Fix search source chips referencing removed badge props

Changes:
- Deezer convertTrack now includes album_type from record_type
- Track creation preserves albumType and source throughout download flow
- Go exports include provider_id in album/artist responses
- Version bump to 3.1.0+59
2026-01-15 18:54:01 +07:00
zarzet 82440affac feat: add home tab enhancements, download queue improvements, and platform bridge updates 2026-01-15 04:31:33 +07:00
zarzet 6d2f75c5dc docs: update screenshots and VirusTotal link for v3.0.0 2026-01-14 21:42:16 +07:00
91 changed files with 68979 additions and 944 deletions
+11
View File
@@ -59,3 +59,14 @@ extension/
# Agent instructions
AGENTS.md
# Temp/misc
nul
# Log files
*.log
hs_err_*.log
flutter_*.log
# Development tools
tool/
+140
View File
@@ -1,5 +1,138 @@
# Changelog
## [3.1.0] - 2026-01-19
### Added
- **Recent Access History**: Quick access to recently visited content when tapping the search bar
- Shows recently visited artists, albums, playlists, and downloaded tracks
- Merged view combining navigation history and download history
- Tap to quickly navigate back to previously accessed content
- X button to remove individual items from history
- "Clear All" button to clear entire history
- Persists across app restarts (stored in SharedPreferences)
- Max 20 items stored, sorted by most recent
- Multi-language support (Artist/Album/Song/Playlist labels localized)
- **Artist Screen Redesign**
- Full-width header image (380px) with gradient overlay
- Artist name displayed at bottom of header with text shadow
- Monthly listeners count display (formatted with compact notation)
- "Popular" section showing top 5 tracks with download status indicators
- Dynamic download button states (queued, downloading, completed)
- Header image and top tracks fetched from extension metadata
- Image alignment set to top-center to show faces properly
- **Extension Store Update Badge**: Badge indicator on Store tab icon showing number of available updates
- Users can see extension updates are available without opening Store tab
- Badge shows count of extensions with updates
- **Extension Compatibility Warning**: Warning badge for extensions requiring newer app version
- Extensions with `minAppVersion` higher than current app show warning label
- Label displays "Requires vX.X.X+" to encourage users to upgrade
- Users can still install the extension (not blocked)
- **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
- `[Year] Album Only`: Albums/[2005] X&Y/
- Year extracted from release date metadata
- Matches desktop SpotiFLAC folder structure
- **Extension Album/Playlist/Artist Support**: Extensions can now return albums, playlists, and artists in search results
- Search results now properly separated into Albums, Playlists, Artists, and Songs sections
- Albums, playlists, and artists show chevron icon (navigate to detail) instead of download button
- Tap album/playlist to view track list and download
- Tap artist to view their albums/discography
- New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions
- New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions
- YouTube Music extension updated with album/playlist/artist support
- **Odesli (song.link) Integration for YouTube Music Extension**
- New `enrichTrack()` function to fetch ISRC and external service links
- Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz
- Enables built-in service fallback for high-quality audio downloads
- Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions
- **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking.
### Changed
- **Search Bar Behavior**: Tapping search bar now immediately moves it to top position
- Logo and subtitle hide when search bar is focused
- Recent access history appears in the content area below
- More space for recent items, not blocked by keyboard
### Fixed
- Fixed search source chips still referencing removed badge props.
- Fixed extension artist album metadata to preserve provider IDs and cover URLs for correct navigation.
- Fixed extension playlist fetch to populate provider IDs and reject disabled extensions.
- Fixed extension collection screens calling setState after dispose during async loads.
- Fixed URL handler responses to include provider IDs for extension albums and artists.
- Fixed YTMusic extension not extracting album name and duration from search results.
- Album name is now extracted from flexColumns/subtitle when linked to album browseId.
- Duration is now extracted from fixedColumns/flexColumns in addition to existing sources.
- Fixed "Separate Singles" setting not working ([#54](https://github.com/zarzet/SpotiFLAC-Mobile/issues/54)) - singles were going to Albums folder.
- Root cause: `albumType` was not being extracted from Deezer API during metadata enrichment.
- Deezer track responses now correctly include `album_type` (single/ep/album/compilation).
- Track creation now preserves `albumType` and `source` fields throughout download flow.
- Fixed PageView overscroll at edges (BouncingScrollPhysics → ClampingScrollPhysics)
- Fixed settings item highlight on swipe (highlightColor: Colors.transparent)
- Fixed extension duplicate load error (skip silently instead of throwing error)
- Fixed keyboard appearing when swiping between tabs (unfocus on page change)
- Removed "Free"/"API Key" badges from search source selector
- Fixed cancel action briefly resuming downloads in the queue UI after ~1 second.
- Fixed cancelled downloads being marked as failed when the backend returns after cancellation.
- Fixed cancel triggering provider fallback (cancel now stops the download flow immediately).
- Fixed stale ISRC cache returning deleted files after cancel.
- Fixed search results mixing extension and built-in artists when using default provider.
- Fixed audio files opening with non-music apps by passing audio MIME type on open.
- Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags.
- Fixed `use_build_context_synchronously` lint warnings in `home_tab.dart`
- Fixed `unnecessary_underscores` lint warnings in error widget callbacks
- Fixed duplicate artist entries in recent history (recording now only happens in screen's initState)
- **Go Backend: Missing `item_type` and `album_type` fields**
- Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct
- Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response
- Fixed `HandleURLWithExtensionJSON` - now includes `item_type` and `album_type` for tracks
- Fixed `GetAlbumWithExtensionJSON` - now includes `item_type` and `album_type` for album tracks
- Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks
- **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists
- **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error
- **Recent Access UI**: Fixed recent access list disappearing when keyboard is dismissed - now stays visible until user presses Back button
- **Extension Artist Top Tracks**: Fixed top tracks not appearing when opening artist from extension search results
- YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs
- Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter
- `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen`
- `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors)
- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field
- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API
### Extensions
- **YouTube Music Extension**: Updated to v1.5.0
- `getArtist()` now returns `top_tracks` array with popular songs
- Added `header_image` and `listeners` to artist response
- **Spotify Web Extension**: Updated to v1.6.0
### Localization
- **Multi-Language Support**: App now supports multiple languages with community contributions via Crowdin
- Available languages: English, Indonesian (Bahasa Indonesia)
- More languages coming soon with community translations
- Contribute translations at [Crowdin](https://crowdin.com/project/spotiflac-mobile)
- Added new localization strings for recent access types:
- `recentTypeArtist` - "Artist" / "Artis"
- `recentTypeAlbum` - "Album" / "Album"
- `recentTypeSong` - "Song" / "Lagu"
- `recentTypePlaylist` - "Playlist" / "Playlist"
- `recentPlaylistInfo` - "Playlist: {name}"
- `errorGeneric` - "Error: {message}"
---
## [3.0.0] - 2026-01-14
### Extension System (Major Feature)
@@ -58,6 +191,13 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- Based on `album_type` from Spotify/Deezer metadata
- Toggle in Settings > Download > Separate Singles Folder
- **Year in Album Folder Name**: New album folder structure options with release year
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
- `[Year] Album Only`: Albums/[2005] X&Y/
- Year extracted from release date metadata
- Matches desktop SpotiFLAC folder structure
- **Parallel API Calls**: Download URL fetching now uses parallel requests
- Tidal: All 8 APIs requested simultaneously, first success wins
- Qobuz: Both APIs requested simultaneously, first success wins
+28 -15
View File
@@ -1,5 +1,6 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
@@ -23,22 +24,14 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## Metadata Source
## Search Source
SpotiFLAC supports two metadata sources for searching tracks:
SpotiFLAC supports two search sources:
| Source | Pros | Cons |
|--------|------|------|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
### Using Spotify
To use Spotify as your search source without hitting rate limits:
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
2. Create an app to get your Client ID and Client Secret
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
4. Enter your Client ID and Secret
5. Change **Search Source** to Spotify
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
## Extensions
@@ -59,6 +52,26 @@ Want to create your own extension? Check out the [Extension Development Guide](h
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## FAQ
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
**Q: Can I download my Spotify playlists?**
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**Q: How do I download Daily Mix or Discover Weekly?**
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
## Disclaimer
@@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"cancelDownload" -> {
val itemId = call.argument<String>("item_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.cancelDownload(itemId)
}
result.success(null)
}
"setDownloadDirectory" -> {
val path = call.argument<String>("path") ?: ""
withContext(Dispatchers.IO) {
@@ -572,6 +579,30 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"getAlbumWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val albumId = call.argument<String>("album_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
}
result.success(response)
}
"getPlaylistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val playlistId = call.argument<String>("playlist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
}
result.success(response)
}
"getArtistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val artistId = call.argument<String>("artist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
}
result.success(response)
}
// Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+3
View File
@@ -0,0 +1,3 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
+20 -1
View File
@@ -1,9 +1,11 @@
package gobackend
import (
"context"
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -346,13 +348,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
@@ -361,6 +371,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
resp, err := a.client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -400,6 +413,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -527,6 +543,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
+79
View File
@@ -0,0 +1,79 @@
package gobackend
import (
"context"
"errors"
"sync"
)
// ErrDownloadCancelled is returned when a download is cancelled by the user.
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
cancel context.CancelFunc
canceled bool
}
var (
cancelMu sync.Mutex
cancelMap = make(map[string]*cancelEntry)
)
func initDownloadCancel(itemID string) context.Context {
if itemID == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
cancel: cancel,
canceled: false,
}
return ctx
}
func cancelDownload(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
if ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
cancelMap[itemID] = &cancelEntry{canceled: true}
}
cancelMu.Unlock()
// Hide progress for cancelled items.
RemoveItemProgress(itemID)
}
func isDownloadCancelled(itemID string) bool {
if itemID == "" {
return false
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
canceled := ok && entry.canceled
cancelMu.Unlock()
return canceled
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
delete(cancelMap, itemID)
cancelMu.Unlock()
}
+1 -3
View File
@@ -89,11 +89,9 @@ type deezerAlbumSimple struct {
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
}
// ... (skip other structs as they are fine/unchanged) ...
// ... (in convertTrack) ...
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := track.Artist.Name
if len(track.Contributors) > 0 {
+24 -1
View File
@@ -103,6 +103,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
return path, exists
}
// remove deletes an ISRC entry from the index (internal use)
func (idx *ISRCIndex) remove(isrc string) {
if isrc == "" {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
delete(idx.index, strings.ToUpper(isrc))
}
// Lookup checks if an ISRC exists in the index (gomobile compatible)
// Returns filepath if found, empty string if not found
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
@@ -138,7 +150,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
// Use index for fast lookup
idx := GetISRCIndex(outputDir)
return idx.lookup(isrc)
filePath, exists := idx.lookup(isrc)
if !exists {
return "", false
}
if !CheckFileExists(filePath) {
// Stale index entry; remove it and return not found.
idx.remove(isrc)
return "", false
}
return filePath, true
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
+311 -6
View File
@@ -5,9 +5,12 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
)
// ParseSpotifyURL parses and validates a Spotify URL
@@ -150,6 +153,10 @@ type DownloadRequest struct {
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"`
}
// DownloadResponse represents the result of a download
@@ -399,7 +406,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
}
} else {
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
}
err = tidalErr
@@ -418,7 +425,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
}
} else {
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
}
err = qobuzErr
@@ -437,12 +444,16 @@ func DownloadWithFallback(requestJSON string) (string, error) {
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
}
} else {
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
}
err = amazonErr
}
if err != nil && errors.Is(err, ErrDownloadCancelled) {
return errorResponse("Download cancelled")
}
if err == nil {
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
@@ -536,6 +547,11 @@ 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() {
@@ -1025,6 +1041,8 @@ func errorResponse(msg string) (string, error) {
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
errorType = "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
errorType = "cancelled"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
@@ -1516,6 +1534,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType, // track, album, or playlist
"album_type": track.AlbumType, // album, single, ep, compilation
}
}
@@ -1613,6 +1633,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response["tracks"] = tracks
@@ -1627,15 +1649,20 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"cover_url": result.Album.CoverURL,
"release_date": result.Album.ReleaseDate,
"total_tracks": result.Album.TotalTracks,
"album_type": result.Album.AlbumType,
"provider_id": result.Album.ProviderID,
}
}
// Add artist info if present
if result.Artist != nil {
artistResponse := map[string]interface{}{
"id": result.Artist.ID,
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
"id": result.Artist.ID,
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
"header_image": result.Artist.HeaderImage,
"listeners": result.Artist.Listeners,
"provider_id": result.Artist.ProviderID,
}
// Add albums if present
@@ -1651,14 +1678,39 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"name": album.Name,
"artists": album.Artists,
"images": album.CoverURL,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": albumType,
"provider_id": album.ProviderID,
}
}
artistResponse["albums"] = albums
}
// Add top tracks if present
if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks {
topTracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"spotify_id": track.SpotifyID,
}
}
artistResponse["top_tracks"] = topTracks
}
response["artist"] = artistResponse
}
@@ -1681,6 +1733,259 @@ func FindURLHandlerJSON(url string) string {
return handler.extension.ID
}
// GetAlbumWithExtensionJSON gets album tracks using an extension
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
album, err := provider.GetAlbum(albumID)
if err != nil {
return "", err
}
if album == nil {
return "", fmt.Errorf("album not found")
}
// Convert tracks to map format
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
// Use album cover as fallback if track doesn't have its own cover
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
}
tracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response := map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"tracks": tracks,
"provider_id": album.ProviderID,
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
return extension.getPlaylist(%q);
}
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
return extension.getAlbum(%q);
}
return null;
})()
`, playlistID, playlistID)
result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout)
if err != nil {
return "", fmt.Errorf("getPlaylist failed: %w", err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("playlist not found")
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
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)
}
album.ProviderID = ext.ID
for i := range album.Tracks {
album.Tracks[i].ProviderID = ext.ID
}
// Convert tracks to map format
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
// Use playlist cover as fallback if track doesn't have its own cover
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
}
tracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response := map[string]interface{}{
"id": album.ID,
"name": album.Name,
"owner": album.Artists,
"cover_url": album.CoverURL,
"total_tracks": album.TotalTracks,
"tracks": tracks,
"provider_id": album.ProviderID,
}
jsonBytes, err = json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetArtistWithExtensionJSON gets artist info and albums using an extension
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
artist, err := provider.GetArtist(artistID)
if err != nil {
return "", err
}
if artist == nil {
return "", fmt.Errorf("artist not found")
}
// Convert albums to map format
albums := make([]map[string]interface{}, len(artist.Albums))
for i, album := range artist.Albums {
albums[i] = map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"provider_id": album.ProviderID,
}
}
response := map[string]interface{}{
"id": artist.ID,
"name": artist.Name,
"cover_url": artist.ImageURL,
"albums": albums,
"provider_id": artist.ProviderID,
}
// Add header image if present
if artist.HeaderImage != "" {
response["header_image"] = artist.HeaderImage
}
// Add listeners if present
if artist.Listeners > 0 {
response["listeners"] = artist.Listeners
}
// Add top tracks if present
if len(artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
for i, track := range artist.TopTracks {
topTracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"spotify_id": track.SpotifyID,
}
}
response["top_tracks"] = topTracks
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetURLHandlersJSON returns all extensions that handle custom URLs
func GetURLHandlersJSON() (string, error) {
manager := GetExtensionManager()
+73 -5
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -29,6 +30,14 @@ type ExtTrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
// Enrichment fields from Odesli/song.link
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
}
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
@@ -54,11 +63,14 @@ type ExtAlbumMetadata struct {
// ExtArtistMetadata represents artist metadata from an extension
type ExtArtistMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
ProviderID string `json:"provider_id"`
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
Listeners int `json:"listeners,omitempty"` // Monthly listeners
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
ProviderID string `json:"provider_id"`
}
// ExtSearchResult represents search results from an extension
@@ -730,6 +742,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
req.ISRC = enrichedTrack.ISRC
}
// Update service-specific IDs from Odesli enrichment
if enrichedTrack.TidalID != "" {
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
req.TidalID = enrichedTrack.TidalID
}
if enrichedTrack.QobuzID != "" {
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
req.QobuzID = enrichedTrack.QobuzID
}
if enrichedTrack.DeezerID != "" {
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
req.DeezerID = enrichedTrack.DeezerID
}
// Can also update other fields if needed
if enrichedTrack.Name != "" {
req.TrackName = enrichedTrack.Name
@@ -814,6 +839,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: req.Source,
}, nil
}
lastErr = err
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
@@ -858,6 +891,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
return result, nil
}
if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerID,
}, nil
}
lastErr = err
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
}
@@ -943,6 +984,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerID,
}, nil
}
lastErr = err
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
@@ -1192,6 +1241,25 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
for i := range handleResult.Tracks {
handleResult.Tracks[i].ProviderID = p.extension.ID
}
if handleResult.Album != nil {
handleResult.Album.ProviderID = p.extension.ID
for i := range handleResult.Album.Tracks {
handleResult.Album.Tracks[i].ProviderID = p.extension.ID
}
}
if handleResult.Artist != nil {
handleResult.Artist.ProviderID = p.extension.ID
for i := range handleResult.Artist.Albums {
handleResult.Artist.Albums[i].ProviderID = p.extension.ID
for j := range handleResult.Artist.Albums[i].Tracks {
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
// Set provider ID on top tracks
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
}
return &handleResult, nil
}
+3
View File
@@ -240,6 +240,9 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
return 0, ErrDownloadCancelled
}
n, err := pw.writer.Write(p)
if err != nil {
return n, err
+65 -2
View File
@@ -1,9 +1,11 @@
package gobackend
import (
"context"
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -367,6 +369,35 @@ 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")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
}
var track QobuzTrack
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
return nil, err
}
return &track, nil
}
// GetAvailableAPIs returns list of available Qobuz APIs
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string {
@@ -835,19 +866,30 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -887,6 +929,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -936,8 +981,23 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
var track *QobuzTrack
var err error
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
}
}
}
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
@@ -1051,6 +1111,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled
}
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
+94 -7
View File
@@ -1,10 +1,12 @@
package gobackend
import (
"context"
"bufio"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
@@ -886,29 +888,45 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
// DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Handle manifest-based download (DASH/BTS)
if strings.HasPrefix(downloadURL, "MANIFEST:") {
// Initialize progress tracking for manifest downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
}
// Initialize item progress for direct downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -948,6 +966,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -968,7 +989,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil
}
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
fmt.Println("[Tidal] Parsing manifest...")
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
@@ -987,7 +1008,11 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile)
req, err := http.NewRequest("GET", directURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil)
if err != nil {
GoLog("[Tidal] BTS request creation failed: %v\n", err)
return fmt.Errorf("failed to create request: %w", err)
@@ -995,6 +1020,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
resp, err := client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] BTS download failed: %v\n", err)
return fmt.Errorf("failed to download file: %w", err)
}
@@ -1030,6 +1058,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if closeErr != nil {
@@ -1062,10 +1093,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
// Download initialization segment
GoLog("[Tidal] Downloading init segment...\n")
resp, err := client.Get(initURL)
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
GoLog("[Tidal] Init segment request failed: %v\n", err)
return fmt.Errorf("failed to create init segment request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Init segment download failed: %v\n", err)
return fmt.Errorf("failed to download init segment: %w", err)
}
@@ -1081,6 +1127,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Init segment write failed: %v\n", err)
return fmt.Errorf("failed to write init segment: %w", err)
}
@@ -1088,6 +1137,12 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
// Download media segments with progress
totalSegments := len(mediaURLs)
for i, mediaURL := range mediaURLs {
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
return ErrDownloadCancelled
}
if i%10 == 0 || i == totalSegments-1 {
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
}
@@ -1098,10 +1153,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
SetItemProgress(itemID, progress, 0, 0)
}
resp, err := client.Get(mediaURL)
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
}
@@ -1117,6 +1182,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
}
@@ -1457,8 +1525,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
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)
if err != nil {
GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name)
}
}
}
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
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)
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
@@ -1670,6 +1754,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}())
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return TidalDownloadResult{}, ErrDownloadCancelled
}
GoLog("[Tidal] Download failed with error: %v\n", err)
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
+30
View File
@@ -120,6 +120,12 @@ import Gobackend // Import Go framework
let itemId = args["item_id"] as! String
GobackendClearItemProgress(itemId)
return nil
case "cancelDownload":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendCancelDownload(itemId)
return nil
case "setDownloadDirectory":
let args = call.arguments as! [String: Any]
@@ -503,6 +509,30 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getAlbumWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let albumId = args["album_id"] as! String
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
if let error = error { throw error }
return response
case "getPlaylistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let playlistId = args["playlist_id"] as! String
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
if let error = error { throw error }
return response
case "getArtistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let artistId = args["artist_id"] as! String
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
if let error = error { throw error }
return response
// Extension Post-Processing API
case "runPostProcessing":
let args = call.arguments as! [String: Any]
+17
View File
@@ -4,6 +4,23 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>de</string>
<string>es</string>
<string>fr</string>
<string>hi</string>
<string>id</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pt</string>
<string>ru</string>
<string>zh</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
</array>
<key>CFBundleDisplayName</key>
<string>SpotiFLAC</string>
<key>CFBundleExecutable</key>
+6
View File
@@ -0,0 +1,6 @@
arb-dir: lib/l10n/arb
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n
nullable-getter: false
+18
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
@@ -31,6 +33,13 @@ class SpotiFLACApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
// Convert locale string to Locale object
Locale? locale;
if (localeString != 'system') {
locale = Locale(localeString);
}
return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) {
@@ -43,6 +52,15 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
// Localization
locale: locale, // null = follow system
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
);
},
);
+2 -2
View File
@@ -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.0.0';
static const String buildNumber = '57';
static const String version = '3.1.0';
static const String buildNumber = '59';
static const String fullVersion = '$version+$buildNumber';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+681
View File
@@ -0,0 +1,681 @@
{
"@@locale": "id",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"navHome": "Beranda",
"navHistory": "Riwayat",
"navSettings": "Pengaturan",
"navStore": "Toko",
"homeTitle": "Beranda",
"homeSearchHint": "Tempel URL Spotify atau cari...",
"homeSearchHintExtension": "Cari dengan {extensionName}...",
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
"homeSupports": "Mendukung: URL Track, Album, Playlist, Artis",
"homeRecent": "Terbaru",
"historyTitle": "Riwayat",
"historyDownloading": "Mengunduh ({count})",
"historyDownloaded": "Terunduh",
"historyFilterAll": "Semua",
"historyFilterAlbums": "Album",
"historyFilterSingles": "Single",
"historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}",
"historyNoDownloads": "Tidak ada riwayat unduhan",
"historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini",
"historyNoAlbums": "Tidak ada unduhan album",
"historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini",
"historyNoSingles": "Tidak ada unduhan single",
"historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini",
"settingsTitle": "Pengaturan",
"settingsDownload": "Unduhan",
"settingsAppearance": "Tampilan",
"settingsOptions": "Opsi",
"settingsExtensions": "Ekstensi",
"settingsAbout": "Tentang",
"downloadTitle": "Unduhan",
"downloadLocation": "Lokasi Unduhan",
"downloadLocationSubtitle": "Pilih tempat menyimpan file",
"downloadLocationDefault": "Lokasi default",
"downloadDefaultService": "Layanan Default",
"downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan",
"downloadDefaultQuality": "Kualitas Default",
"downloadAskQuality": "Tanya Kualitas Sebelum Unduh",
"downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan",
"downloadFilenameFormat": "Format Nama File",
"downloadFolderOrganization": "Organisasi Folder",
"downloadSeparateSingles": "Pisahkan Single",
"downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah",
"qualityBest": "Terbaik",
"qualityFlac": "FLAC",
"quality320": "320 kbps",
"quality128": "128 kbps",
"appearanceTitle": "Tampilan",
"appearanceTheme": "Tema",
"appearanceThemeSystem": "Sistem",
"appearanceThemeLight": "Terang",
"appearanceThemeDark": "Gelap",
"appearanceDynamicColor": "Warna Dinamis",
"appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda",
"appearanceAccentColor": "Warna Aksen",
"appearanceHistoryView": "Tampilan Riwayat",
"appearanceHistoryViewList": "Daftar",
"appearanceHistoryViewGrid": "Grid",
"optionsTitle": "Opsi",
"optionsSearchSource": "Sumber Pencarian",
"optionsPrimaryProvider": "Provider Utama",
"optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.",
"optionsUsingExtension": "Menggunakan ekstensi: {extensionName}",
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
"optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu",
"optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan",
"optionsEmbedLyrics": "Sematkan Lirik",
"optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC",
"optionsMaxQualityCover": "Cover Kualitas Maksimal",
"optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi",
"optionsConcurrentDownloads": "Unduhan Bersamaan",
"optionsConcurrentSequential": "Berurutan (1 per waktu)",
"optionsConcurrentParallel": "{count} unduhan paralel",
"optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate",
"optionsExtensionStore": "Toko Ekstensi",
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
"optionsCheckUpdates": "Periksa Pembaruan",
"optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia",
"optionsUpdateChannel": "Saluran Pembaruan",
"optionsUpdateChannelStable": "Hanya rilis stabil",
"optionsUpdateChannelPreview": "Dapatkan rilis preview",
"optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap",
"optionsClearHistory": "Hapus Riwayat Unduhan",
"optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat",
"optionsDetailedLogging": "Log Detail",
"optionsDetailedLoggingOn": "Log detail sedang direkam",
"optionsDetailedLoggingOff": "Aktifkan untuk laporan bug",
"optionsSpotifyCredentials": "Kredensial Spotify",
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur",
"optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com",
"extensionsTitle": "Ekstensi",
"extensionsInstalled": "Ekstensi Terpasang",
"extensionsNone": "Tidak ada ekstensi terpasang",
"extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko",
"extensionsEnabled": "Aktif",
"extensionsDisabled": "Nonaktif",
"extensionsVersion": "Versi {version}",
"extensionsAuthor": "oleh {author}",
"extensionsUninstall": "Copot",
"extensionsSetAsSearch": "Jadikan Provider Pencarian",
"storeTitle": "Toko Ekstensi",
"storeSearch": "Cari ekstensi...",
"storeInstall": "Pasang",
"storeInstalled": "Terpasang",
"storeUpdate": "Perbarui",
"aboutTitle": "Tentang",
"aboutContributors": "Kontributor",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pencipta SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kami yang indah!",
"aboutSpecialThanks": "Terima Kasih Khusus",
"aboutLinks": "Tautan",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutSupport": "Dukungan",
"aboutBuyMeCoffee": "Traktir saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutApp": "Aplikasi",
"aboutVersion": "Versi",
"albumTitle": "Album",
"albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"albumDownloadAll": "Unduh Semua",
"albumDownloadRemaining": "Unduh Sisanya",
"playlistTitle": "Playlist",
"artistTitle": "Artis",
"artistAlbums": "Album",
"artistSingles": "Single & EP",
"trackMetadataTitle": "Info Lagu",
"trackMetadataArtist": "Artis",
"trackMetadataAlbum": "Album",
"trackMetadataDuration": "Durasi",
"trackMetadataQuality": "Kualitas",
"trackMetadataPath": "Lokasi File",
"trackMetadataDownloadedAt": "Diunduh",
"trackMetadataService": "Layanan",
"trackMetadataPlay": "Putar",
"trackMetadataShare": "Bagikan",
"trackMetadataDelete": "Hapus",
"trackMetadataRedownload": "Unduh ulang",
"trackMetadataOpenFolder": "Buka Folder",
"setupTitle": "Selamat Datang di SpotiFLAC",
"setupSubtitle": "Mari mulai pengaturan",
"setupStoragePermission": "Izin Penyimpanan",
"setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan",
"setupStoragePermissionGranted": "Izin diberikan",
"setupStoragePermissionDenied": "Izin ditolak",
"setupGrantPermission": "Berikan Izin",
"setupDownloadLocation": "Lokasi Unduhan",
"setupChooseFolder": "Pilih Folder",
"setupContinue": "Lanjutkan",
"setupSkip": "Lewati untuk sekarang",
"dialogCancel": "Batal",
"dialogOk": "OK",
"dialogSave": "Simpan",
"dialogDelete": "Hapus",
"dialogRetry": "Coba Lagi",
"dialogClose": "Tutup",
"dialogYes": "Ya",
"dialogNo": "Tidak",
"dialogClear": "Hapus",
"dialogConfirm": "Konfirmasi",
"dialogDone": "Selesai",
"dialogClearHistoryTitle": "Hapus Riwayat",
"dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.",
"dialogDeleteSelectedTitle": "Hapus yang Dipilih",
"dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.",
"dialogImportPlaylistTitle": "Impor Playlist",
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
"snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian",
"snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian",
"snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh",
"snackbarHistoryCleared": "Riwayat dihapus",
"snackbarCredentialsSaved": "Kredensial disimpan",
"snackbarCredentialsCleared": "Kredensial dihapus",
"snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}",
"snackbarCannotOpenFile": "Tidak dapat membuka file: {error}",
"snackbarFillAllFields": "Harap isi semua field",
"snackbarViewQueue": "Lihat Antrian",
"errorRateLimited": "Dibatasi",
"errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.",
"errorFailedToLoad": "Gagal memuat {item}",
"errorNoTracksFound": "Tidak ada lagu ditemukan",
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"statusQueued": "Mengantri",
"statusDownloading": "Mengunduh",
"statusFinalizing": "Menyelesaikan",
"statusCompleted": "Selesai",
"statusFailed": "Gagal",
"statusSkipped": "Dilewati",
"statusPaused": "Dijeda",
"actionPause": "Jeda",
"actionResume": "Lanjutkan",
"actionCancel": "Batal",
"actionStop": "Hentikan",
"actionSelect": "Pilih",
"actionSelectAll": "Pilih Semua",
"actionDeselect": "Batal Pilih",
"actionPaste": "Tempel",
"actionImportCsv": "Impor CSV",
"actionRemoveCredentials": "Hapus Kredensial",
"actionSaveCredentials": "Simpan Kredensial",
"selectionSelected": "{count} dipilih",
"selectionAllSelected": "Semua lagu dipilih",
"selectionTapToSelect": "Ketuk lagu untuk memilih",
"selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"selectionSelectToDelete": "Pilih lagu untuk dihapus",
"progressFetchingMetadata": "Mengambil metadata... {current}/{total}",
"progressReadingCsv": "Membaca CSV...",
"searchSongs": "Lagu",
"searchArtists": "Artis",
"searchAlbums": "Album",
"searchPlaylists": "Playlist",
"tooltipPlay": "Putar",
"tooltipCancel": "Batal",
"tooltipStop": "Hentikan",
"tooltipRetry": "Coba Lagi",
"tooltipRemove": "Hapus",
"tooltipClear": "Hapus",
"tooltipPaste": "Tempel",
"filenameFormat": "Format Nama File",
"filenameFormatPreview": "Pratinjau: {preview}",
"folderOrganization": "Organisasi Folder",
"folderOrganizationNone": "Tanpa organisasi",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByArtistAlbum": "Artis/Album",
"updateAvailable": "Pembaruan Tersedia",
"updateNewVersion": "Versi {version} tersedia",
"updateDownload": "Unduh",
"updateLater": "Nanti",
"updateChangelog": "Log Perubahan",
"providerPriority": "Prioritas Provider",
"providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan",
"metadataProviderPriority": "Prioritas Provider Metadata",
"metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu",
"logTitle": "Log",
"logCopy": "Salin Log",
"logClear": "Hapus Log",
"logShare": "Bagikan Log",
"logEmpty": "Belum ada log",
"logCopied": "Log disalin ke clipboard",
"credentialsTitle": "Kredensial Spotify",
"credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.",
"credentialsClientId": "Client ID",
"credentialsClientIdHint": "Tempel Client ID",
"credentialsClientSecret": "Client Secret",
"credentialsClientSecretHint": "Tempel Client Secret",
"channelStable": "Stabil",
"channelPreview": "Preview",
"sectionSearchSource": "Sumber Pencarian",
"sectionDownload": "Unduhan",
"sectionPerformance": "Performa",
"sectionApp": "Aplikasi",
"sectionData": "Data",
"sectionDebug": "Debug",
"sectionService": "Layanan",
"sectionAudioQuality": "Kualitas Audio",
"sectionFileSettings": "Pengaturan File",
"sectionColor": "Warna",
"sectionTheme": "Tema",
"sectionLayout": "Tata Letak",
"sectionLanguage": "Bahasa",
"appearanceLanguage": "Bahasa Aplikasi",
"appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan",
"languageSystem": "Bawaan Sistem",
"languageEnglish": "English",
"languageIndonesian": "Bahasa Indonesia",
"settingsAppearanceSubtitle": "Tema, warna, tampilan",
"settingsDownloadSubtitle": "Layanan, kualitas, format nama file",
"settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan",
"settingsExtensionsSubtitle": "Kelola provider unduhan",
"settingsLogsSubtitle": "Lihat log aplikasi untuk debugging",
"loadingSharedLink": "Memuat link yang dibagikan...",
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
"artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}",
"artistCompilations": "Kompilasi",
"artistPopular": "Populer",
"artistMonthlyListeners": "{count} pendengar bulanan",
"tracksHeader": "Lagu",
"downloadAllCount": "Unduh Semua ({count})",
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"setupStorageAccessRequired": "Akses Penyimpanan Diperlukan",
"setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.",
"setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.",
"setupOpenSettings": "Buka Pengaturan",
"setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.",
"setupPermissionRequired": "Izin {permissionType} Diperlukan",
"setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.",
"setupSelectDownloadFolder": "Pilih Folder Unduhan",
"setupUseDefaultFolder": "Gunakan Folder Default?",
"setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?",
"setupUseDefault": "Gunakan Default",
"setupDownloadLocationTitle": "Lokasi Unduhan",
"setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.",
"setupAppDocumentsFolder": "Folder Documents Aplikasi",
"setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files",
"setupChooseFromFiles": "Pilih dari Files",
"setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya",
"setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.",
"setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC",
"setupStepStorage": "Penyimpanan",
"setupStepNotification": "Notifikasi",
"setupStepFolder": "Folder",
"setupStepSpotify": "Spotify",
"setupStepPermission": "Izin",
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
"setupStorageRequired": "Izin Penyimpanan Diperlukan",
"setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.",
"setupNotificationGranted": "Izin Notifikasi Diberikan!",
"setupNotificationEnable": "Aktifkan Notifikasi",
"setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.",
"setupFolderSelected": "Folder Unduhan Dipilih!",
"setupFolderChoose": "Pilih Folder Unduhan",
"setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.",
"setupChangeFolder": "Ubah Folder",
"setupSelectFolder": "Pilih Folder",
"setupSpotifyApiOptional": "Spotify API (Opsional)",
"setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.",
"setupUseSpotifyApi": "Gunakan Spotify API",
"setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah",
"setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)",
"setupEnterClientId": "Masukkan Spotify Client ID",
"setupEnterClientSecret": "Masukkan Spotify Client Secret",
"setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.",
"setupEnableNotifications": "Aktifkan Notifikasi",
"dialogImport": "Impor",
"dialogDiscard": "Buang",
"dialogRemove": "Hapus",
"dialogUninstall": "Copot",
"dialogDiscardChanges": "Buang Perubahan?",
"dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?",
"dialogDownloadFailed": "Unduhan Gagal",
"dialogTrackLabel": "Lagu:",
"dialogArtistLabel": "Artis:",
"dialogErrorLabel": "Error:",
"dialogClearAll": "Hapus Semua",
"dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?",
"dialogRemoveFromDevice": "Hapus dari perangkat?",
"dialogRemoveExtension": "Hapus Ekstensi",
"dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.",
"dialogUninstallExtension": "Copot Ekstensi?",
"dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?",
"snackbarFailedToLoad": "Gagal memuat: {error}",
"snackbarUrlCopied": "URL {platform} disalin ke clipboard",
"snackbarFileNotFound": "File tidak ditemukan",
"snackbarSelectExtFile": "Harap pilih file .spotiflac-ext",
"snackbarProviderPrioritySaved": "Prioritas provider disimpan",
"snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan",
"snackbarExtensionInstalled": "{extensionName} terpasang.",
"snackbarExtensionUpdated": "{extensionName} diperbarui.",
"snackbarFailedToInstall": "Gagal memasang ekstensi",
"snackbarFailedToUpdate": "Gagal memperbarui ekstensi",
"storeFilterAll": "Semua",
"storeFilterMetadata": "Metadata",
"storeFilterDownload": "Unduhan",
"storeFilterUtility": "Utilitas",
"storeFilterLyrics": "Lirik",
"storeFilterIntegration": "Integrasi",
"storeClearFilters": "Hapus filter",
"storeNoResults": "Tidak ada ekstensi ditemukan",
"extensionProviderPriority": "Prioritas Provider",
"extensionInstallButton": "Pasang Ekstensi",
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan",
"extensionAuthor": "Pembuat",
"extensionId": "ID",
"extensionError": "Error",
"extensionCapabilities": "Kemampuan",
"extensionMetadataProvider": "Provider Metadata",
"extensionDownloadProvider": "Provider Unduhan",
"extensionLyricsProvider": "Provider Lirik",
"extensionUrlHandler": "Penanganan URL",
"extensionQualityOptions": "Opsi Kualitas",
"extensionPostProcessingHooks": "Hook Pasca-Pemrosesan",
"extensionPermissions": "Izin",
"extensionSettings": "Pengaturan",
"extensionRemoveButton": "Hapus Ekstensi",
"extensionUpdated": "Diperbarui",
"extensionMinAppVersion": "Versi App Minimum",
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLosslessSubtitle": "16-bit / 44.1kHz",
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
"downloadDirectory": "Direktori Unduhan",
"downloadSeparateSinglesFolder": "Folder Singles Terpisah",
"downloadAlbumFolderStructure": "Struktur Folder Album",
"downloadSaveFormat": "Simpan Format",
"downloadSelectService": "Pilih Layanan",
"downloadSelectQuality": "Pilih Kualitas",
"downloadFrom": "Unduh Dari",
"downloadDefaultQualityLabel": "Kualitas Default",
"downloadBestAvailable": "Terbaik tersedia",
"folderNone": "Tidak ada",
"folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan",
"folderArtist": "Artis",
"folderArtistSubtitle": "Nama Artis/namafile",
"folderAlbum": "Album",
"folderAlbumSubtitle": "Nama Album/namafile",
"folderArtistAlbum": "Artis/Album",
"folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile",
"serviceTidal": "Tidal",
"serviceQobuz": "Qobuz",
"serviceAmazon": "Amazon",
"serviceDeezer": "Deezer",
"serviceSpotify": "Spotify",
"logSearchHint": "Cari log...",
"logFilterLevel": "Level",
"logFilterSection": "Filter",
"logShareLogs": "Bagikan log",
"logClearLogs": "Hapus log",
"logClearLogsTitle": "Hapus Log",
"logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?",
"logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI",
"logRateLimited": "DIBATASI",
"logNetworkError": "ERROR JARINGAN",
"logTrackNotFound": "LAGU TIDAK DITEMUKAN",
"appearanceAmoledDark": "AMOLED Gelap",
"appearanceAmoledDarkSubtitle": "Latar belakang hitam murni",
"appearanceChooseAccentColor": "Pilih Warna Aksen",
"appearanceChooseTheme": "Mode Tema",
"updateStartingDownload": "Memulai unduhan...",
"updateDownloadFailed": "Unduhan gagal",
"updateFailedMessage": "Gagal mengunduh pembaruan",
"updateNewVersionReady": "Versi baru sudah siap",
"updateCurrent": "Saat ini",
"updateNew": "Baru",
"updateDownloading": "Mengunduh...",
"updateWhatsNew": "Yang Baru",
"updateDownloadInstall": "Unduh & Pasang",
"updateDontRemind": "Jangan ingatkan",
"trackCopyFilePath": "Salin lokasi file",
"trackRemoveFromDevice": "Hapus dari perangkat",
"trackLoadLyrics": "Muat Lirik",
"dateToday": "Hari ini",
"dateYesterday": "Kemarin",
"dateDaysAgo": "{count} hari lalu",
"dateWeeksAgo": "{count} minggu lalu",
"dateMonthsAgo": "{count} bulan lalu",
"concurrentSequential": "Berurutan",
"concurrentParallel2": "2 Paralel",
"concurrentParallel3": "3 Paralel",
"filenameAvailablePlaceholders": "Placeholder yang tersedia:",
"filenameHint": "{artist} - {title}",
"tapToSeeError": "Ketuk untuk melihat detail error",
"setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.",
"setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.",
"setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.",
"setupSkipForNow": "Lewati untuk sekarang",
"setupBack": "Kembali",
"setupNext": "Lanjut",
"setupGetStarted": "Mulai",
"setupSkipAndStart": "Lewati & Mulai",
"setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.",
"setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com",
"trackMetadata": "Metadata",
"trackFileInfo": "Info File",
"trackLyrics": "Lirik",
"trackFileNotFound": "File tidak ditemukan",
"trackOpenInDeezer": "Buka di Deezer",
"trackOpenInSpotify": "Buka di Spotify",
"trackTrackName": "Nama lagu",
"trackArtist": "Artis",
"trackAlbumArtist": "Artis album",
"trackAlbum": "Album",
"trackTrackNumber": "Nomor lagu",
"trackDiscNumber": "Nomor disc",
"trackDuration": "Durasi",
"trackAudioQuality": "Kualitas audio",
"trackReleaseDate": "Tanggal rilis",
"trackDownloaded": "Diunduh",
"trackCopyLyrics": "Salin lirik",
"trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini",
"trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.",
"trackLyricsLoadFailed": "Gagal memuat lirik",
"trackCopiedToClipboard": "Disalin ke clipboard",
"trackDeleteConfirmTitle": "Hapus dari perangkat?",
"trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.",
"trackCannotOpen": "Tidak dapat membuka: {message}",
"logFilterBySeverity": "Filter log berdasarkan tingkat keparahan",
"logNoLogsYet": "Belum ada log",
"logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi",
"logIssueSummary": "Ringkasan Masalah",
"logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan",
"logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8",
"logRateLimitedDescription": "Terlalu banyak permintaan ke layanan",
"logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi",
"logNetworkErrorDescription": "Masalah koneksi terdeteksi",
"logNetworkErrorSuggestion": "Periksa koneksi internet Anda",
"logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan",
"logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless",
"logTotalErrors": "Total error: {count}",
"logAffected": "Terpengaruh: {domains}",
"logEntriesFiltered": "Entri ({count} difilter)",
"logEntries": "Entri ({count})",
"extensionsProviderPrioritySection": "Prioritas Provider",
"extensionsInstalledSection": "Ekstensi Terpasang",
"extensionsNoExtensions": "Tidak ada ekstensi terpasang",
"extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru",
"extensionsInstallButton": "Pasang Ekstensi",
"extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.",
"extensionsInstalledSuccess": "Ekstensi berhasil dipasang",
"extensionsDownloadPriority": "Prioritas Unduhan",
"extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan",
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"extensionsMetadataPriority": "Prioritas Metadata",
"extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata",
"extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata",
"extensionsSearchProvider": "Provider Pencarian",
"extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom",
"extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu",
"extensionsCustomSearch": "Pencarian kustom",
"extensionsErrorLoading": "Error memuat ekstensi",
"extensionCustomTrackMatching": "Pencocokan Lagu Kustom",
"extensionPostProcessing": "Pasca-Pemrosesan",
"extensionHooksAvailable": "{count} hook tersedia",
"extensionPatternsCount": "{count} pola",
"extensionStrategy": "Strategi: {strategy}",
"aboutDoubleDouble": "DoubleDouble",
"aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!",
"aboutDabMusic": "DAB Music",
"aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!",
"queueTitle": "Antrian Unduhan",
"queueClearAll": "Hapus Semua",
"queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?",
"albumFolderArtistAlbum": "Artis / Album",
"albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/",
"albumFolderArtistYearAlbum": "Artis / [Tahun] Album",
"albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/",
"albumFolderAlbumOnly": "Album Saja",
"albumFolderAlbumOnlySubtitle": "Albums/Nama Album/",
"albumFolderYearAlbum": "[Tahun] Album",
"albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/",
"downloadedAlbumDeleteSelected": "Hapus yang Dipilih",
"downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.",
"utilityFunctions": "Fungsi Utilitas",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pembuat SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!",
"aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!",
"aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutBuyMeCoffee": "Belikan saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutVersion": "Versi",
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"providerPriorityTitle": "Prioritas Provider",
"providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.",
"providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.",
"providerBuiltIn": "Bawaan",
"providerExtension": "Ekstensi",
"metadataProviderPriorityTitle": "Prioritas Metadata",
"metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.",
"metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.",
"metadataNoRateLimits": "Tidak ada batas rate",
"metadataMayRateLimit": "Mungkin dibatasi rate",
"queueEmpty": "Tidak ada unduhan dalam antrian",
"queueEmptySubtitle": "Tambahkan lagu dari layar beranda",
"queueClearCompleted": "Hapus yang selesai",
"queueDownloadFailed": "Unduhan Gagal",
"queueTrackLabel": "Lagu:",
"queueArtistLabel": "Artis:",
"queueErrorLabel": "Error:",
"queueUnknownError": "Error tidak diketahui",
"downloadedAlbumTracksHeader": "Lagu",
"downloadedAlbumDownloadedCount": "{count} diunduh",
"downloadedAlbumSelectedCount": "{count} dipilih",
"downloadedAlbumAllSelected": "Semua lagu dipilih",
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
"folderOrganizationNone": "Tidak ada",
"folderOrganizationNoneSubtitle": "Semua file di folder unduhan",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album",
"folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album",
"folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album",
"recentTypeArtist": "Artis",
"recentTypeAlbum": "Album",
"recentTypeSong": "Lagu",
"recentTypePlaylist": "Playlist",
"recentPlaylistInfo": "Playlist: {name}",
"errorGeneric": "Error: {message}"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
export 'package:spotiflac_android/l10n/app_localizations.dart';
/// Extension to easily access AppLocalizations from BuildContext
extension AppLocalizationsX on BuildContext {
/// Get the AppLocalizations instance
/// Usage: context.l10n.navHome
AppLocalizations get l10n => AppLocalizations.of(this);
}
+24
View File
@@ -0,0 +1,24 @@
// GENERATED FILE - DO NOT EDIT
// 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 70
import 'package:flutter/widgets.dart';
/// Minimum translation completion threshold used to filter languages.
const int translationThreshold = 70;
/// List of locales that meet the translation threshold.
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('id'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'id',
};
+5 -1
View File
@@ -28,8 +28,9 @@ class AppSettings {
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 or album_only
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.
const AppSettings({
this.defaultService = 'tidal',
@@ -58,6 +59,7 @@ class AppSettings {
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
});
AppSettings copyWith({
@@ -88,6 +90,7 @@ class AppSettings {
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -116,6 +119,7 @@ class AppSettings {
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
);
}
+4 -1
View File
@@ -32,8 +32,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -64,4 +66,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
};
+14
View File
@@ -20,6 +20,7 @@ class Track {
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
const Track({
required this.id,
@@ -37,10 +38,23 @@ class Track {
this.availability,
this.source,
this.albumType,
this.itemType,
});
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
+2
View File
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
itemType: json['itemType'] as String?,
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'itemType': instance.itemType,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
+120 -21
View File
@@ -18,6 +18,14 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
String? _normalizeOptionalString(String? value) {
if (value == null) return null;
final trimmed = value.trim();
if (trimmed.isEmpty) return null;
if (trimmed.toLowerCase() == 'null') return null;
return trimmed;
}
// Download History Item model
class DownloadHistoryItem {
final String id;
@@ -89,7 +97,7 @@ class DownloadHistoryItem {
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
albumArtist: _normalizeOptionalString(json['albumArtist'] as String?),
coverUrl: json['coverUrl'] as String?,
filePath: json['filePath'] as String,
service: json['service'] as String,
@@ -492,6 +500,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
for (final entry in items.entries) {
final itemId = entry.key;
final localItem = state.items
.where((i) => i.id == itemId)
.firstOrNull;
if (localItem == null) {
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.completed ||
localItem.status == DownloadStatus.failed) {
continue;
}
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
@@ -671,6 +693,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// Build output directory based on folder organization setting and separateSingles
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
String baseDir = state.outputDir;
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
// If separateSingles is enabled, use Albums/Singles structure
if (separateSingles) {
@@ -688,15 +711,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else {
// Albums folder structure based on setting
final albumName = _sanitizeFolderName(track.albumName);
final artistName = _sanitizeFolderName(albumArtist);
final year = _extractYear(track.releaseDate);
String albumPath;
if (albumFolderStructure == 'album_only') {
// Albums/Album structure (no artist folder)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
} else {
// Albums/Artist/Album structure (default)
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
switch (albumFolderStructure) {
case 'album_only':
// Albums/Album structure (no artist folder)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
break;
case 'artist_year_album':
// Albums/Artist/[Year] Album structure
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
break;
case 'year_album':
// Albums/[Year] Album structure (no artist folder)
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
break;
default:
// Albums/Artist/Album structure (default: artist_album)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
}
final dir = Directory(albumPath);
@@ -716,7 +752,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String subPath = '';
switch (folderOrganization) {
case 'artist':
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
final artistName = _sanitizeFolderName(albumArtist);
subPath = artistName;
break;
case 'album':
@@ -724,7 +760,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
subPath = albumName;
break;
case 'artist_album':
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
final artistName = _sanitizeFolderName(albumArtist);
final albumName = _sanitizeFolderName(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName';
break;
@@ -751,6 +787,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.trim();
}
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
// Handle both "2005-06-13" and "2005" formats
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
return match?.group(1);
}
void updateSettings(AppSettings settings) {
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty
@@ -853,6 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
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 ||
item.status == DownloadStatus.completed ||
item.status == DownloadStatus.failed) {
return;
}
updateItemStatus(
id,
DownloadStatus.downloading,
@@ -863,6 +914,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void cancelItem(String id) {
updateItemStatus(id, DownloadStatus.skipped);
PlatformBridge.cancelDownload(id).catchError((_) {});
PlatformBridge.clearItemProgress(id).catchError((_) {});
}
void clearCompleted() {
@@ -981,7 +1034,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'album_artist': track.albumArtist ?? track.artistName,
'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName,
'track_number': track.trackNumber ?? 1,
'disc_number': track.discNumber ?? 1,
'isrc': track.isrc ?? '',
@@ -1084,9 +1137,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
if (track.albumArtist != null) {
metadata['ALBUMARTIST'] = track.albumArtist!;
}
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
@@ -1394,6 +1447,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
final currentItem = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled before start, skipping');
return;
}
// Set currentDownload for UI reference
state = state.copyWith(currentDownload: item);
@@ -1440,6 +1502,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final data = trackData;
_log.d('Track data keys: ${data.keys.toList()}');
_log.d('ISRC from API: ${data['isrc']}');
_log.d('album_type from API: ${data['album_type']}');
trackToDownload = Track(
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
name: (data['name'] as String?) ?? trackToDownload.name,
@@ -1461,9 +1524,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: data['release_date'] as String?,
deezerId: rawId,
availability: trackToDownload.availability,
// Preserve albumType from API response or original track
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
source: trackToDownload.source,
);
_log.d(
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}',
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}, AlbumType ${trackToDownload.albumType}',
);
} else {
_log.w('Unexpected track data type: ${trackData.runtimeType}');
@@ -1480,6 +1546,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Log cover URL for debugging CSV import issues
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
final normalizedAlbumArtist =
_normalizeOptionalString(trackToDownload.albumArtist);
final outputDir = await _buildOutputDir(
trackToDownload,
settings.folderOrganization,
@@ -1510,7 +1579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -1534,7 +1603,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -1555,7 +1624,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -1620,7 +1689,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Actual quality: $actualQuality');
}
// M4A files from Tidal DASH streams - try to convert to FLAC
// M4A files from Tidal DASH streams - try to convert to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
_log.d(
@@ -1690,7 +1758,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: backendAlbum ?? trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: trackToDownload.isrc,
@@ -1699,6 +1767,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: backendYear ?? trackToDownload.releaseDate,
deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
source: trackToDownload.source,
);
}
@@ -1779,6 +1849,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Log cover URL for debugging
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
(normalizedAlbumArtist != null &&
normalizedAlbumArtist != trackToDownload.artistName)
? normalizedAlbumArtist
: null;
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
@@ -1793,7 +1869,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
? backendAlbum
: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: historyAlbumArtist,
coverUrl: trackToDownload.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? item.service,
@@ -1822,8 +1898,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
removeItem(item.id);
}
} else {
final itemAfterFailure = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterFailure.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling');
return;
}
final errorMsg = result['error'] as String? ?? 'Download failed';
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
if (errorTypeStr == 'cancelled') {
_log.i('Download was cancelled by backend, skipping error handling');
updateItemStatus(item.id, DownloadStatus.skipped);
return;
}
// Convert error type string to enum
DownloadErrorType errorType;
@@ -1867,6 +1957,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} catch (e, stackTrace) {
final itemAfterError = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterError.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling');
return;
}
_log.e('Exception: $e', e, stackTrace);
String errorMsg = e.toString();
+248
View File
@@ -0,0 +1,248 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history';
const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType {
artist,
album,
track,
playlist,
}
/// Represents a recently accessed item
class RecentAccessItem {
final String id;
final String name;
final String? subtitle; // Artist name for tracks/albums, null for artists
final String? imageUrl;
final RecentAccessType type;
final DateTime accessedAt;
final String? providerId; // Extension ID or 'deezer' for built-in
const RecentAccessItem({
required this.id,
required this.name,
this.subtitle,
this.imageUrl,
required this.type,
required this.accessedAt,
this.providerId,
});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'subtitle': subtitle,
'imageUrl': imageUrl,
'type': type.name,
'accessedAt': accessedAt.toIso8601String(),
'providerId': providerId,
};
factory RecentAccessItem.fromJson(Map<String, dynamic> json) {
return RecentAccessItem(
id: json['id'] as String,
name: json['name'] as String,
subtitle: json['subtitle'] as String?,
imageUrl: json['imageUrl'] as String?,
type: RecentAccessType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => RecentAccessType.track,
),
accessedAt: DateTime.parse(json['accessedAt'] as String),
providerId: json['providerId'] as String?,
);
}
/// Create a unique key for deduplication
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RecentAccessItem &&
runtimeType == other.runtimeType &&
uniqueKey == other.uniqueKey;
@override
int get hashCode => uniqueKey.hashCode;
}
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final bool isLoaded;
const RecentAccessState({
this.items = const [],
this.isLoaded = false,
});
RecentAccessState copyWith({
List<RecentAccessItem>? items,
bool? isLoaded,
}) {
return RecentAccessState(
items: items ?? this.items,
isLoaded: isLoaded ?? this.isLoaded,
);
}
}
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
@override
RecentAccessState build() {
_loadHistory();
return const RecentAccessState();
}
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_recentAccessKey);
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
final items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
// Invalid JSON, start fresh
state = state.copyWith(isLoaded: true);
}
} else {
state = state.copyWith(isLoaded: true);
}
}
Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
required String name,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
imageUrl: imageUrl,
type: RecentAccessType.artist,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to an album
void recordAlbumAccess({
required String id,
required String name,
String? artistName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.album,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to a track
void recordTrackAccess({
required String id,
required String name,
String? artistName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.track,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to a playlist
void recordPlaylistAccess({
required String id,
required String name,
String? ownerName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: ownerName,
imageUrl: imageUrl,
type: RecentAccessType.playlist,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
void _recordAccess(RecentAccessItem item) {
// Debug log
// ignore: avoid_print
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
// Remove any existing entry with same unique key
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
// Add new item at the beginning
updatedItems.insert(0, item);
// Limit to max items
if (updatedItems.length > _maxRecentItems) {
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
}
state = state.copyWith(items: updatedItems);
_saveHistory();
// Debug log
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
}
/// Remove a specific item from history
void removeItem(RecentAccessItem item) {
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
state = state.copyWith(items: updatedItems);
_saveHistory();
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
}
}
/// Provider instance
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
+5
View File
@@ -230,6 +230,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();
}
void setLocale(String locale) {
state = state.copyWith(locale: locale);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+30
View File
@@ -1,10 +1,29 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
/// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1;
if (n1 > n2) return 1;
}
return 0;
}
/// Extension categories
class StoreCategory {
static const String metadata = 'metadata';
@@ -91,6 +110,12 @@ class StoreExtension {
hasUpdate: json['has_update'] as bool? ?? false,
);
}
/// Check if this extension requires a higher app version than current
bool get requiresNewerApp {
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
return compareVersions(minAppVersion!, AppInfo.version) > 0;
}
}
/// State for extension store
@@ -161,6 +186,11 @@ class StoreState {
return result;
}
/// Count of extensions with updates available
int get updatesAvailableCount {
return extensions.where((e) => e.hasUpdate).length;
}
}
/// Provider for managing extension store
+66 -4
View File
@@ -17,9 +17,13 @@ class TrackState {
final String? artistId;
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final int? monthlyListeners; // Artist monthly listeners
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results
const TrackState({
@@ -32,9 +36,13 @@ class TrackState {
this.artistId,
this.artistName,
this.coverUrl,
this.headerImageUrl,
this.monthlyListeners,
this.artistAlbums,
this.artistTopTracks,
this.searchArtists,
this.hasSearchText = false,
this.isShowingRecentAccess = false,
this.searchExtensionId,
});
@@ -50,9 +58,13 @@ class TrackState {
String? artistId,
String? artistName,
String? coverUrl,
String? headerImageUrl,
int? monthlyListeners,
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
List<SearchArtist>? searchArtists,
bool? hasSearchText,
bool? isShowingRecentAccess,
String? searchExtensionId,
}) {
return TrackState(
@@ -65,9 +77,13 @@ class TrackState {
artistId: artistId ?? this.artistId,
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
searchArtists: searchArtists ?? this.searchArtists,
hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId,
);
}
@@ -82,6 +98,7 @@ class ArtistAlbum {
final String? coverUrl;
final String albumType; // album, single, compilation
final String artists;
final String? providerId; // Extension ID if from extension
const ArtistAlbum({
required this.id,
@@ -91,6 +108,7 @@ class ArtistAlbum {
this.coverUrl,
required this.albumType,
required this.artists,
this.providerId,
});
}
@@ -169,13 +187,21 @@ class TrackNotifier extends Notifier<TrackState> {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?,
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
headerImageUrl: artistData['header_image'] as String?,
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
searchExtensionId: extensionId,
);
return;
@@ -275,12 +301,19 @@ class TrackNotifier extends Notifier<TrackState> {
final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions;
final searchProvider = settings.searchProvider;
final useExtensions =
settings.useExtensionProviders &&
hasActiveMetadataExtensions &&
searchProvider != null &&
searchProvider.isNotEmpty;
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
);
Map<String, dynamic> results;
List<Track> extensionTracks = [];
@@ -453,6 +486,8 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: track.trackNumber,
discNumber: track.discNumber,
releaseDate: track.releaseDate,
albumType: track.albumType,
source: track.source,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
qobuz: availability['qobuz'] as bool? ?? false,
@@ -479,6 +514,28 @@ class TrackNotifier extends Notifier<TrackState> {
void setSearchText(bool hasText) {
state = state.copyWith(hasSearchText: hasText);
}
/// Set recent access mode state
void setShowingRecentAccess(bool showing) {
state = state.copyWith(isShowingRecentAccess: showing);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
String? albumName,
String? playlistName,
String? coverUrl,
}) {
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumName,
playlistName: playlistName,
coverUrl: coverUrl,
hasSearchText: state.hasSearchText,
);
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
@@ -506,13 +563,16 @@ class TrackNotifier extends Notifier<TrackState> {
durationMs = durationValue.toInt();
}
// Get item_type - can be 'track', 'album', or 'playlist'
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: data['images']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -520,6 +580,7 @@ class TrackNotifier extends Notifier<TrackState> {
releaseDate: data['release_date']?.toString(),
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
}
@@ -529,9 +590,10 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
);
}
+25 -10
View File
@@ -2,10 +2,12 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -62,6 +64,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
@override
void initState() {
super.initState();
// Record access for recent history
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
// Priority: widget.tracks > cache > fetch
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
if (_tracks == null) {
@@ -260,7 +275,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -269,7 +284,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -289,7 +304,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -324,12 +339,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -344,12 +359,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: widget.albumName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
@@ -375,7 +390,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -383,7 +398,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -476,7 +491,7 @@ class _AlbumTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+632 -172
View File
@@ -1,50 +1,87 @@
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:intl/intl.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
/// Simple in-memory cache for artist discography
/// Simple in-memory cache for artist data
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static List<ArtistAlbum>? get(String artistId) {
static _CacheEntry? get(String artistId) {
final entry = _cache[artistId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(artistId);
return null;
}
return entry.albums;
return entry;
}
static void set(String artistId, List<ArtistAlbum> albums) {
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
static void set(String artistId, {
required List<ArtistAlbum> albums,
List<Track>? topTracks,
String? headerImageUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
albums: albums,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
monthlyListeners: monthlyListeners,
expiresAt: DateTime.now().add(_ttl),
);
}
}
class _CacheEntry {
final List<ArtistAlbum> albums;
final List<Track>? topTracks;
final String? headerImageUrl;
final int? monthlyListeners;
final DateTime expiresAt;
_CacheEntry(this.albums, this.expiresAt);
_CacheEntry({
required this.albums,
this.topTracks,
this.headerImageUrl,
this.monthlyListeners,
required this.expiresAt,
});
}
/// Artist screen with Material Expressive 3 design - shows discography
/// Artist screen with Spotify-like design
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
final String? coverUrl;
final List<ArtistAlbum>? albums; // Optional - will fetch if null
final String? headerImageUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
final String? extensionId; // If set, skip fetching from Spotify/Deezer
const ArtistScreen({
super.key,
required this.artistId,
required this.artistName,
this.coverUrl,
this.headerImageUrl,
this.monthlyListeners,
this.albums,
this.topTracks,
this.extensionId,
});
@override
@@ -54,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget {
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
List<Track>? _topTracks;
String? _headerImageUrl;
int? _monthlyListeners;
String? _error;
@override
void initState() {
super.initState();
// Priority: widget.albums > cache > fetch
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
if (_albums == null) {
// Record access for recent history
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordArtistAccess(
id: widget.artistId,
name: widget.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
// If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer
if (widget.extensionId != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
// Extension artists don't need additional fetching
return;
}
// Priority: widget data > cache > fetch
// But always fetch if topTracks is missing (to get popular tracks)
final cached = _ArtistCache.get(widget.artistId);
if (widget.albums != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
// If we have albums but no top tracks, fetch to get them
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else if (cached != null) {
_albums = cached.albums;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_monthlyListeners = cached.monthlyListeners;
// If cache has no top tracks, fetch
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else {
_fetchDiscography();
}
}
@@ -70,31 +155,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
setState(() => _isLoadingDiscography = true);
try {
List<ArtistAlbum> albums;
List<Track>? topTracks;
String? headerImage;
int? listeners;
// Check if this is a Deezer artist ID (format: "deezer:123456")
if (widget.artistId.startsWith('deezer:')) {
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
} else {
// Spotify artist - use fallback method
// ignore: avoid_print
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
// Spotify artist - use extension handler via URL
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?;
} else {
// Fallback to Spotify API metadata
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
}
}
// Store in cache
_ArtistCache.set(widget.artistId, albums);
// Store in cache (preserve existing values if new ones are null)
final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners;
_ArtistCache.set(
widget.artistId,
albums: albums,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
monthlyListeners: finalListeners,
);
if (mounted) {
setState(() {
_albums = albums;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_monthlyListeners = finalListeners;
_isLoadingDiscography = false;
});
}
@@ -108,15 +222,41 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
source: data['provider_id']?.toString(),
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
);
}
@@ -129,43 +269,63 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
body: CustomScrollView(
slivers: [
_buildHeader(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
// Popular tracks section
if (_topTracks != null && _topTracks!.isNotEmpty)
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
// Discography sections
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
if (singles.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)),
if (compilations.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
// Validate image URL - must be non-null, non-empty, and have a valid host
final hasValidImage = widget.coverUrl != null &&
widget.coverUrl!.isNotEmpty &&
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
/// Build Spotify-style header with full-width image and artist name overlay
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
// Use header image if available, otherwise fall back to cover URL
// Prefer: fetched header > widget header > widget cover
String? imageUrl = _headerImageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.headerImageUrl;
}
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.coverUrl;
}
final hasValidImage = imageUrl != null &&
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
// Format monthly listeners
String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) {
final formatter = NumberFormat.compact();
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
}
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 380,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -174,49 +334,84 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Background image - full width, no circular crop
if (hasValidImage)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces)
memCacheWidth: 800,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
),
)
else
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
),
// Gradient overlay for text readability
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],
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7),
colorScheme.surface,
],
stops: const [0.0, 0.5, 0.75, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
// Artist name and listeners at bottom
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
child: ClipOval(
child: hasValidImage
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 280,
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
),
)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
),
],
],
),
),
],
@@ -224,44 +419,280 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
if (_albums != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
/// Build Popular tracks section like Spotify
Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
// Show max 5 tracks
final tracks = _topTracks!.take(5).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
context.l10n.artistPopular,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return _buildPopularTrackItem(index + 1, track, colorScheme);
}),
],
);
}
/// Build a single popular track item with dynamic download status
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
// Watch download queue for this track's status
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return InkWell(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Rank number
SizedBox(
width: 24,
child: Text(
'$rank',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
// Album art
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: track.coverUrl != null
? CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
placeholder: (context, url) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
),
)
: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
),
),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (track.albumName.isNotEmpty)
Text(
track.albumName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Download button with status
_buildPopularDownloadButton(
track: track,
colorScheme: colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
progress: progress,
),
],
),
),
);
}
/// Handle tap on popular track item
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async {
if (isQueued) return;
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
);
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
_downloadTrack(track);
}
/// Build download button with status indicator for popular tracks
Widget _buildPopularDownloadButton({
required Track track,
required ColorScheme colorScheme,
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 40.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize),
),
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 2.5,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: colorScheme.primary),
),
],
),
);
} else if (isQueued) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize),
);
} else {
return GestureDetector(
onTap: () => _downloadTrack(track),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize),
),
);
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
duration: const Duration(seconds: 2),
),
);
}
@@ -271,24 +702,26 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Icon(Icons.album, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
],
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'$title (${albums.length})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
height: 210,
height: 220,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(album, colorScheme),
);
},
),
),
@@ -301,62 +734,90 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
onTap: () => _navigateToAlbum(album),
child: Container(
width: 140,
margin: const EdgeInsets.symmetric(horizontal: 6),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverUrl != null
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
),
const SizedBox(height: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album cover
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: 140,
height: 140,
fit: BoxFit.cover,
memCacheWidth: 280,
placeholder: (context, url) => Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
),
],
),
),
],
errorWidget: (context, url, error) => Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
),
)
: Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
),
),
),
const SizedBox(height: 8),
// Album name
Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Year and track count
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
void _navigateToAlbum(ArtistAlbum album) {
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
ref.read(settingsProvider.notifier).setHasSearchedBefore();
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
// tracks: null - will be fetched in AlbumScreen
),
));
if (album.providerId != null && album.providerId!.isNotEmpty) {
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: album.providerId!,
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
),
));
} else {
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
),
));
}
}
/// 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') ||
@@ -366,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
@@ -378,7 +839,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -386,7 +847,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -401,11 +862,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
+18 -15
View File
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -83,19 +85,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
child: Text(context.l10n.dialogDelete),
),
],
),
@@ -124,7 +126,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
}
}
@@ -132,11 +134,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Future<void> _openFile(String filePath) async {
try {
await OpenFilex.open(filePath);
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
@@ -321,7 +324,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -374,13 +377,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
@@ -521,11 +524,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$selectedCount selected',
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? 'All tracks selected' : 'Tap tracks to select',
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
@@ -540,7 +543,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? 'Deselect' : 'Select All'),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
@@ -553,8 +556,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
+93 -9
View File
@@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.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';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
final isCollection = track.isCollection;
// Determine subtitle text based on item type
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
@@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
track.artistName,
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => _downloadTrack(index),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
);
}
Future<void> _openCollection(Track track) async {
// Get the extension ID from the track source
final extensionId = track.source;
if (extensionId == null) return;
// Fetch album/playlist tracks using the extension
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
+1080 -61
View File
File diff suppressed because it is too large Load Diff
+41 -19
View File
@@ -2,8 +2,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/store_tab.dart';
@@ -77,7 +79,7 @@ class _MainShellState extends ConsumerState<MainShell> {
// Show snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Loading shared link...')),
SnackBar(content: Text(context.l10n.loadingSharedLink)),
);
}
}
@@ -123,7 +125,8 @@ class _MainShellState extends ConsumerState<MainShell> {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
// Unfocus any text field when switching tabs to prevent keyboard from appearing
FocusScope.of(context).unfocus();
// Use primaryFocus for more aggressive unfocus that works with keep-alive widgets
FocusManager.instance.primaryFocus?.unfocus();
}
}
@@ -134,7 +137,15 @@ class _MainShellState extends ConsumerState<MainShell> {
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusScope.of(context).unfocus();
FocusManager.instance.primaryFocus?.unfocus();
return;
}
// If on Home tab and showing recent access mode, exit it
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
// Also unfocus search bar when exiting recent access mode
FocusManager.instance.primaryFocus?.unfocus();
return;
}
@@ -162,9 +173,9 @@ class _MainShellState extends ConsumerState<MainShell> {
} else {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Press back again to exit'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.pressBackAgainToExit),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
@@ -176,6 +187,7 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider);
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
@@ -187,6 +199,7 @@ class _MainShellState extends ConsumerState<MainShell> {
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading &&
!trackState.isShowingRecentAccess &&
!isKeyboardVisible;
// Build tabs and destinations based on settings
@@ -201,11 +214,12 @@ class _MainShellState extends ConsumerState<MainShell> {
const SettingsTab(),
];
final l10n = context.l10n;
final destinations = <NavigationDestination>[
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: l10n.navHome,
),
NavigationDestination(
icon: Badge(
@@ -218,18 +232,26 @@ class _MainShellState extends ConsumerState<MainShell> {
label: Text('$queueState'),
child: const Icon(Icons.history),
),
label: 'History',
label: l10n.navHistory,
),
if (showStore)
const NavigationDestination(
icon: Icon(Icons.store_outlined),
selectedIcon: Icon(Icons.store),
label: 'Store',
NavigationDestination(
icon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
),
label: l10n.navStore,
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: l10n.navSettings,
),
];
+9 -8
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -114,7 +115,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -122,7 +123,7 @@ class PlaylistScreen extends ConsumerWidget {
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -141,7 +142,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -176,12 +177,12 @@ class PlaylistScreen extends ConsumerWidget {
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -195,12 +196,12 @@ class PlaylistScreen extends ConsumerWidget {
artistName: playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
}
@@ -264,7 +265,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+16 -15
View File
@@ -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/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -14,19 +15,19 @@ class QueueScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Download Queue'),
title: Text(context.l10n.queueTitle),
actions: [
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: 'Clear completed',
tooltip: context.l10n.queueClearCompleted,
),
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: 'Clear all',
tooltip: context.l10n.queueClearAll,
),
],
),
@@ -51,14 +52,14 @@ class QueueScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
Text(
'No downloads in queue',
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add tracks from the home screen',
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
@@ -177,7 +178,7 @@ class QueueScreen extends ConsumerWidget {
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
const Text('Download Failed'),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
@@ -185,10 +186,10 @@ class QueueScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Artist: ${item.track.artistName}'),
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
@@ -197,7 +198,7 @@ class QueueScreen extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? 'Unknown error',
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
@@ -211,7 +212,7 @@ class QueueScreen extends ConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.dialogClose),
),
],
),
@@ -223,19 +224,19 @@ class QueueScreen extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All'),
content: const Text('Are you sure you want to clear all downloads?'),
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
+14 -38
View File
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -138,21 +140,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text(
'Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.',
),
title: Text(context.l10n.dialogDeleteSelectedTitle),
content: Text(context.l10n.dialogDeleteSelectedMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
child: Text(context.l10n.dialogDelete),
),
],
),
@@ -183,9 +183,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}',
),
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
@@ -228,35 +226,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Future<void> _openFile(String filePath) async {
final cleanPath = _cleanFilePath(filePath);
try {
// Determine MIME type based on file extension
final extension = cleanPath.split('.').last.toLowerCase();
String mimeType;
switch (extension) {
case 'flac':
mimeType = 'audio/flac';
break;
case 'mp3':
mimeType = 'audio/mpeg';
break;
case 'wav':
mimeType = 'audio/wav';
break;
case 'm4a':
case 'aac':
mimeType = 'audio/mp4';
break;
case 'ogg':
mimeType = 'audio/ogg';
break;
default:
mimeType = 'audio/*';
}
final mimeType = audioMimeTypeForPath(cleanPath);
await OpenFilex.open(cleanPath, type: mimeType);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))));
}
}
}
@@ -514,7 +490,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'History',
context.l10n.historyTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
@@ -611,7 +587,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
child: Row(
children: [
_FilterChip(
label: 'All',
label: context.l10n.historyFilterAll,
count: allHistoryItems.length,
isSelected: historyFilterMode == 'all',
onTap: () {
@@ -620,7 +596,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 8),
_FilterChip(
label: 'Albums',
label: context.l10n.historyFilterAlbums,
count: albumCount,
isSelected: historyFilterMode == 'albums',
onTap: () {
@@ -629,7 +605,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 8),
_FilterChip(
label: 'Singles',
label: context.l10n.historyFilterSingles,
count: singleCount,
isSelected: historyFilterMode == 'singles',
onTap: () {
@@ -805,7 +781,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
? () => _enterSelectionMode(historyItems.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
+39 -38
View File
@@ -2,6 +2,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/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget {
@@ -41,7 +42,7 @@ class AboutPage extends StatelessWidget {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'About',
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
@@ -62,27 +63,27 @@ class AboutPage extends StatelessWidget {
),
// Contributors section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Contributors'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: 'Mobile version developer',
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: 'Creator of the original SpotiFLAC',
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: 'The talented artist who created our beautiful app logo!',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
@@ -91,35 +92,35 @@ class AboutPage extends StatelessWidget {
),
// Special Thanks section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Special Thanks'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'uimaxbai',
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
githubUsername: 'uimaxbai',
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: 'The original HiFi project creator. The foundation of Tidal integration!',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.cloud_outlined,
title: 'DoubleDouble',
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
title: context.l10n.aboutDoubleDouble,
subtitle: context.l10n.aboutDoubleDoubleDesc,
onTap: () => _launchUrl('https://doubledouble.top'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: 'DAB Music',
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: false,
),
@@ -128,37 +129,37 @@ class AboutPage extends StatelessWidget {
),
// Links section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Links'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.phone_android,
title: 'Mobile source code',
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.computer,
title: 'PC source code',
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: 'Report an issue',
subtitle: 'Report any problems you encounter',
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: 'Feature request',
subtitle: 'Suggest new features for the app',
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
@@ -167,16 +168,16 @@ class AboutPage extends StatelessWidget {
),
// Support section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Support'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.coffee_outlined,
title: 'Buy me a coffee',
subtitle: 'Support development on Ko-fi',
title: context.l10n.aboutBuyMeCoffee,
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
@@ -185,15 +186,15 @@ class AboutPage extends StatelessWidget {
),
// App info section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.info_outline,
title: 'Version',
title: context.l10n.aboutVersion,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
@@ -300,7 +301,7 @@ class _AppHeaderCard extends StatelessWidget {
const SizedBox(height: 16),
// Description
Text(
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -32,7 +34,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
@@ -49,8 +51,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
// Color section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Color'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionColor),
),
SliverToBoxAdapter(
@@ -58,8 +60,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [
SettingsSwitchItem(
icon: Icons.wallpaper,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
title: context.l10n.appearanceDynamicColor,
subtitle: context.l10n.appearanceDynamicColorSubtitle,
value: themeSettings.useDynamicColor,
onChanged: (value) => ref
.read(themeProvider.notifier)
@@ -82,8 +84,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
// Theme section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Theme'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -96,8 +98,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
if (Theme.of(context).brightness == Brightness.dark)
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background',
title: context.l10n.appearanceAmoledDark,
subtitle: context.l10n.appearanceAmoledDarkSubtitle,
value: themeSettings.useAmoled,
onChanged: (value) =>
ref.read(themeProvider.notifier).setUseAmoled(value),
@@ -107,9 +109,26 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Language section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_LanguageSelector(
currentLocale: settings.locale,
onChanged: (locale) => ref
.read(settingsProvider.notifier)
.setLocale(locale),
),
],
),
),
// Layout section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Layout'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -283,7 +302,7 @@ class _ThemePreviewCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? 'Dark Mode' : 'Light Mode',
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
@@ -451,21 +470,21 @@ class _ThemeModeSelector extends StatelessWidget {
children: [
_ThemeModeChip(
icon: Icons.brightness_auto,
label: 'System',
label: context.l10n.appearanceThemeSystem,
isSelected: currentMode == ThemeMode.system,
onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.light_mode,
label: 'Light',
label: context.l10n.appearanceThemeLight,
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.dark_mode,
label: 'Dark',
label: context.l10n.appearanceThemeDark,
isSelected: currentMode == ThemeMode.dark,
onTap: () => onChanged(ThemeMode.dark),
),
@@ -575,7 +594,7 @@ class _HistoryViewSelector extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'History View',
context.l10n.appearanceHistoryView,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -585,14 +604,14 @@ class _HistoryViewSelector extends StatelessWidget {
children: [
_ViewModeChip(
icon: Icons.view_list,
label: 'List',
label: context.l10n.appearanceHistoryViewList,
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
icon: Icons.grid_view,
label: 'Grid',
label: context.l10n.appearanceHistoryViewGrid,
isSelected: currentMode == 'grid',
onTap: () => onChanged('grid'),
),
@@ -682,3 +701,132 @@ class _ViewModeChip extends StatelessWidget {
);
}
}
class _LanguageSelector extends StatelessWidget {
final String currentLocale;
final ValueChanged<String> onChanged;
const _LanguageSelector({
required this.currentLocale,
required this.onChanged,
});
// All available languages (code, displayName, icon)
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),
('fr', 'Français', Icons.language),
('hi', 'हिन्दी', Icons.language),
('ja', '日本語', Icons.language),
('ko', '한국어', Icons.language),
('nl', 'Nederlands', Icons.language),
('pt', 'Português', Icons.language),
('ru', 'Русский', Icons.language),
('zh', '简体中文', Icons.language),
('zh_TW', '繁體中文', Icons.language),
];
/// Get only languages that meet the translation threshold.
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
List<(String, String, IconData)> get _languages {
return _allLanguages.where((lang) {
// Always include 'system' option
if (lang.$1 == 'system') return true;
// Only include languages in the filtered set
return filteredLocaleCodes.contains(lang.$1);
}).toList();
}
String _getLanguageName(String code) {
// Search in all languages (not just filtered) for display name fallback
for (final lang in _allLanguages) {
if (lang.$1 == code) return lang.$2;
}
return code;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(
Icons.language,
color: colorScheme.onSurfaceVariant,
),
title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
onTap: () => _showLanguagePicker(context),
);
}
void _showLanguagePicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const Divider(height: 1),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _languages.length,
itemBuilder: (context, index) {
final lang = _languages[index];
final isSelected = currentLocale == lang.$1;
return ListTile(
leading: Icon(
lang.$3,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
lang.$2,
style: TextStyle(
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
onChanged(lang.$1);
Navigator.pop(context);
},
);
},
),
),
const SizedBox(height: 8),
],
),
),
);
}
}
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -55,7 +56,7 @@ class DownloadSettingsPage extends ConsumerWidget {
bottom: 16,
),
title: Text(
'Download',
context.l10n.downloadTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
@@ -68,8 +69,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
// Service section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Service'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionService),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -85,17 +86,17 @@ class DownloadSettingsPage extends ConsumerWidget {
),
// Quality section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Audio Quality'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
title: context.l10n.downloadAskBeforeDownload,
subtitle: isBuiltInService
? 'Choose quality for each download'
? context.l10n.downloadAskQualitySubtitle
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
// Not selected visually if extension is active
@@ -106,24 +107,24 @@ class DownloadSettingsPage extends ConsumerWidget {
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
title: context.l10n.qualityFlacLossless,
subtitle: context.l10n.qualityFlacLosslessSubtitle,
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('LOSSLESS'),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
title: context.l10n.qualityHiResFlac,
subtitle: context.l10n.qualityHiResFlacSubtitle,
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES'),
),
_QualityOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
title: context.l10n.qualityHiResFlacMax,
subtitle: context.l10n.qualityHiResFlacMaxSubtitle,
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
@@ -159,15 +160,15 @@ class DownloadSettingsPage extends ConsumerWidget {
),
// File settings section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'File Settings'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
title: context.l10n.downloadFilenameFormat,
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(
context,
@@ -177,17 +178,17 @@ class DownloadSettingsPage extends ConsumerWidget {
),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
title: context.l10n.downloadDirectory,
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS
? 'App Documents Folder'
? context.l10n.setupAppDocumentsFolder
: 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: 'Separate Singles Folder',
title: context.l10n.downloadSeparateSinglesFolder,
subtitle: settings.separateSingles
? 'Albums/ and Singles/ folders'
: 'All files in same structure',
@@ -199,10 +200,8 @@ class DownloadSettingsPage extends ConsumerWidget {
if (settings.separateSingles)
SettingsItem(
icon: Icons.folder_outlined,
title: 'Album Folder Structure',
subtitle: settings.albumFolderStructure == 'album_only'
? 'Albums/Album Name/'
: 'Albums/Artist/Album Name/',
title: context.l10n.downloadAlbumFolderStructure,
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
onTap: () => _showAlbumFolderStructurePicker(
context,
ref,
@@ -212,7 +211,7 @@ class DownloadSettingsPage extends ConsumerWidget {
if (!settings.separateSingles)
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
title: context.l10n.downloadFolderOrganization,
subtitle: _getFolderOrganizationLabel(
settings.folderOrganization,
),
@@ -234,6 +233,19 @@ class DownloadSettingsPage extends ConsumerWidget {
);
}
String _getAlbumFolderStructureLabel(String structure) {
switch (structure) {
case 'album_only':
return 'Albums/Album Name/';
case 'artist_year_album':
return 'Albums/Artist/[Year] Album/';
case 'year_album':
return 'Albums/[Year] Album/';
default:
return 'Albums/Artist/Album Name/';
}
}
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
showModalBottomSheet(
context: context,
@@ -243,24 +255,44 @@ class DownloadSettingsPage extends ConsumerWidget {
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Artist / Album'),
subtitle: const Text('Albums/Artist Name/Album Name/'),
title: Text(context.l10n.albumFolderArtistAlbum),
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.calendar_today_outlined),
title: Text(context.l10n.albumFolderArtistYearAlbum),
subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle),
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: const Text('Album Only'),
subtitle: const Text('Albums/Album Name/'),
title: Text(context.l10n.albumFolderAlbumOnly),
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.event_outlined),
title: Text(context.l10n.albumFolderYearAlbum),
subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle),
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
Navigator.pop(context);
},
),
],
),
),
@@ -336,7 +368,7 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
Text(
'Filename Format',
context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -402,7 +434,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Row(
children: [
Expanded(
child: TextButton(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
@@ -410,7 +442,7 @@ class DownloadSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
),
const SizedBox(width: 12),
@@ -429,7 +461,7 @@ class DownloadSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Save Format'),
child: Text(context.l10n.dialogSave),
),
),
],
@@ -473,7 +505,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Download Location',
context.l10n.setupDownloadLocationTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -482,7 +514,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
context.l10n.setupDownloadLocationIosMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -490,8 +522,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
title: Text(context.l10n.setupAppDocumentsFolder),
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await getApplicationDocumentsDirectory();
@@ -503,8 +535,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
@@ -534,7 +566,7 @@ class DownloadSettingsPage extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
context.l10n.setupIosEmptyFolderWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -558,7 +590,7 @@ class DownloadSettingsPage extends ConsumerWidget {
case 'album':
return 'By Album';
case 'artist_album':
return 'By Artist & Album';
return 'Artist/Album';
default:
return 'None';
}
@@ -598,15 +630,15 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Organize downloaded files into folders',
context.l10n.folderOrganizationDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_FolderOption(
title: 'None',
subtitle: 'All files in download folder',
title: context.l10n.folderOrganizationNone,
subtitle: context.l10n.folderOrganizationNoneSubtitle,
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () {
@@ -615,8 +647,8 @@ class DownloadSettingsPage extends ConsumerWidget {
},
),
_FolderOption(
title: 'By Artist',
subtitle: 'Separate folder for each artist',
title: context.l10n.folderOrganizationByArtist,
subtitle: context.l10n.folderOrganizationByArtistSubtitle,
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () {
@@ -625,8 +657,8 @@ class DownloadSettingsPage extends ConsumerWidget {
},
),
_FolderOption(
title: 'By Album',
subtitle: 'Separate folder for each album',
title: context.l10n.folderOrganizationByAlbum,
subtitle: context.l10n.folderOrganizationByAlbumSubtitle,
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () {
@@ -635,8 +667,8 @@ class DownloadSettingsPage extends ConsumerWidget {
},
),
_FolderOption(
title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album',
title: context.l10n.folderOrganizationByArtistAlbum,
subtitle: context.l10n.folderOrganizationByArtistAlbumSubtitle,
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () {
+34 -34
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
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/widgets/settings_group.dart';
@@ -186,12 +187,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
const SizedBox(height: 16),
_InfoRow(label: 'Author', value: extension.author),
_InfoRow(label: 'ID', value: extension.id),
_InfoRow(label: 'Version', value: 'v${extension.version}'),
_InfoRow(label: context.l10n.extensionAuthor, value: extension.author),
_InfoRow(label: context.l10n.extensionId, value: extension.id),
_InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''),
if (hasError && extension.errorMessage != null)
_InfoRow(
label: 'Error',
label: context.l10n.extensionError,
value: extension.errorMessage!,
isError: true,
),
@@ -202,50 +203,50 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
// Capabilities
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Capabilities'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_CapabilityItem(
icon: Icons.search,
title: 'Metadata Provider',
title: context.l10n.extensionMetadataProvider,
enabled: extension.hasMetadataProvider,
),
_CapabilityItem(
icon: Icons.download,
title: 'Download Provider',
title: context.l10n.extensionDownloadProvider,
enabled: extension.hasDownloadProvider,
),
_CapabilityItem(
icon: Icons.manage_search,
title: 'Custom Search',
title: context.l10n.extensionsSearchProvider,
enabled: extension.hasCustomSearch,
subtitle: extension.searchBehavior?.placeholder,
),
_CapabilityItem(
icon: Icons.compare_arrows,
title: 'Custom Track Matching',
title: context.l10n.extensionCustomTrackMatching,
enabled: extension.hasCustomMatching,
subtitle: extension.trackMatching?.strategy != null
? 'Strategy: ${extension.trackMatching!.strategy}'
? context.l10n.extensionStrategy(extension.trackMatching!.strategy!)
: null,
),
_CapabilityItem(
icon: Icons.auto_fix_high,
title: 'Post-Processing',
title: context.l10n.extensionPostProcessing,
enabled: extension.hasPostProcessing,
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
? '${extension.postProcessing!.hooks.length} hook(s) available'
? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length)
: null,
),
_CapabilityItem(
icon: Icons.link,
title: 'URL Handler',
title: context.l10n.extensionUrlHandler,
enabled: extension.hasURLHandler,
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
? '${extension.urlHandler!.patterns.length} pattern(s)'
? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length)
: null,
showDivider: false,
),
@@ -257,8 +258,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// URL Handler Section (if extension handles URLs)
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'URL Handler'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -273,8 +274,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Quality Options Section (for download providers)
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Quality Options'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -292,8 +293,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Post-Processing Hooks (if available)
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Post-Processing Hooks'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -311,8 +312,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Permissions
if (extension.permissions.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Permissions'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -330,8 +331,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Settings
if (extension.settings.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Settings'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
),
if (_isLoadingSettings)
const SliverToBoxAdapter(
@@ -364,7 +365,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
child: OutlinedButton.icon(
onPressed: () => _confirmRemove(context),
icon: const Icon(Icons.delete_outline),
label: const Text('Remove Extension'),
label: Text(context.l10n.extensionRemoveButton),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
@@ -398,22 +399,21 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove Extension'),
content: const Text(
'Are you sure you want to remove this extension? '
'This action cannot be undone.',
title: Text(context.l10n.dialogRemoveExtension),
content: Text(
context.l10n.dialogRemoveExtensionMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
),
child: const Text('Remove'),
child: Text(context.l10n.dialogRemove),
),
],
),
@@ -725,7 +725,7 @@ class _SettingItem extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
@@ -735,7 +735,7 @@ class _SettingItem extends StatelessWidget {
onChanged(newValue);
Navigator.pop(context);
},
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
),
+29 -29
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
@@ -74,7 +75,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Extensions',
context.l10n.extensionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -123,8 +124,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
// Provider Priority
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Provider Priority'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -137,8 +138,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
// Installed Extensions
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Installed Extensions'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
),
if (extState.extensions.isEmpty && !extState.isLoading)
@@ -160,14 +161,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
const SizedBox(height: 12),
Text(
'No extensions installed',
context.l10n.extensionsNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Install .spotiflac-ext files to add new providers',
context.l10n.extensionsNoExtensionsSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -209,7 +210,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: FilledButton.icon(
onPressed: _installExtension,
icon: const Icon(Icons.add),
label: const Text('Install Extension'),
label: Text(context.l10n.extensionsInstallButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@@ -236,8 +237,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Extensions can add new metadata and download providers. '
'Only install extensions from trusted sources.',
context.l10n.extensionsInfoTip,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -266,8 +266,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
if (!file.path!.endsWith('.spotiflac-ext')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a .spotiflac-ext file'),
SnackBar(
content: Text(context.l10n.snackbarSelectExtFile),
),
);
}
@@ -282,7 +282,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final extState = ref.read(extensionProvider);
String message;
if (success) {
message = 'Extension installed successfully';
message = context.l10n.extensionsInstalledSuccess;
} else {
// Parse friendly error message
message = _getFriendlyErrorMessage(extState.error);
@@ -404,8 +404,8 @@ class _ExtensionItem extends StatelessWidget {
const SizedBox(height: 2),
Text(
hasError
? extension.errorMessage ?? 'Error loading extension'
: 'v${extension.version} by ${extension.author}',
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
@@ -474,7 +474,7 @@ class _DownloadPriorityItem extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Download Priority',
context.l10n.extensionsDownloadPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions
? null
@@ -484,8 +484,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? 'Set download service order'
: 'No extensions with download provider',
? context.l10n.extensionsDownloadPrioritySubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -543,7 +543,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Metadata Priority',
context.l10n.extensionsMetadataPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasMetadataExtensions
? null
@@ -553,8 +553,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
const SizedBox(height: 2),
Text(
hasMetadataExtensions
? 'Set search & metadata source order'
: 'No extensions with metadata provider',
? context.l10n.extensionsMetadataPrioritySubtitle
: context.l10n.extensionsNoMetadataProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -590,7 +590,7 @@ class _SearchProviderSelector extends ConsumerWidget {
.toList();
// Get current provider name
String currentProviderName = 'Default (Deezer/Spotify)';
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
currentProviderName = ext?.displayName ?? settings.searchProvider!;
@@ -619,7 +619,7 @@ class _SearchProviderSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Search Provider',
context.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: searchProviders.isEmpty
? colorScheme.outline
@@ -629,7 +629,7 @@ class _SearchProviderSelector extends ConsumerWidget {
const SizedBox(height: 2),
Text(
searchProviders.isEmpty
? 'No extensions with custom search'
? context.l10n.extensionsNoCustomSearch
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -674,7 +674,7 @@ class _SearchProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Search Provider',
ctx.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -683,7 +683,7 @@ class _SearchProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose which service to use for searching tracks',
ctx.l10n.extensionsSearchProviderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -692,8 +692,8 @@ class _SearchProviderSelector extends ConsumerWidget {
// Default option
ListTile(
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: const Text('Default (Deezer/Spotify)'),
subtitle: const Text('Use built-in search'),
title: Text(ctx.l10n.extensionDefaultProvider),
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -706,7 +706,7 @@ class _SearchProviderSelector extends ConsumerWidget {
...searchProviders.map((ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
trailing: settings.searchProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
+23 -20
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -67,7 +68,7 @@ class _LogScreenState extends State<LogScreen> {
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Logs copied to clipboard'),
content: Text(context.l10n.logCopied),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
@@ -84,19 +85,19 @@ class _LogScreenState extends State<LogScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs?'),
title: Text(context.l10n.logClearLogsTitle),
content: Text(context.l10n.logClearLogsMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
LogBuffer().clear();
Navigator.pop(context);
},
child: const Text('Clear'),
child: Text(context.l10n.dialogClear),
),
],
),
@@ -166,19 +167,19 @@ class _LogScreenState extends State<LogScreen> {
}
},
itemBuilder: (context) => [
const PopupMenuItem(
PopupMenuItem(
value: 'share',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Share logs'),
leading: const Icon(Icons.share),
title: Text(context.l10n.logShareLogs),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Clear logs'),
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.logClearLogs),
contentPadding: EdgeInsets.zero,
),
),
@@ -195,7 +196,7 @@ class _LogScreenState extends State<LogScreen> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Logs',
context.l10n.logTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -208,8 +209,8 @@ class _LogScreenState extends State<LogScreen> {
),
// Filter section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Filter'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -225,10 +226,10 @@ class _LogScreenState extends State<LogScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
'Filter logs by severity',
context.l10n.logFilterBySeverity,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -279,7 +280,7 @@ class _LogScreenState extends State<LogScreen> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search logs...',
hintText: context.l10n.logSearchHint,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -316,7 +317,9 @@ class _LogScreenState extends State<LogScreen> {
// Log entries section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
? context.l10n.logEntriesFiltered(logs.length)
: context.l10n.logEntries(logs.length),
),
),
@@ -342,14 +345,14 @@ class _LogScreenState extends State<LogScreen> {
),
const SizedBox(height: 16),
Text(
'No logs yet',
context.l10n.logNoLogsYet,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Logs will appear here as you use the app',
context.l10n.logNoLogsYetSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
@@ -81,7 +82,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
@@ -96,7 +97,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Metadata Priority',
context.l10n.metadataProviderPriorityTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -113,8 +114,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder metadata providers. The app will try providers '
'from top to bottom when searching for tracks and fetching metadata.',
context.l10n.metadataProviderPriorityDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -166,8 +166,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
const SizedBox(width: 12),
Expanded(
child: Text(
'Deezer has no rate limits and is recommended as primary. '
'Spotify may rate limit after many requests.',
context.l10n.metadataProviderPriorityInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -190,16 +189,16 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard Changes?'),
content: const Text('You have unsaved changes. Do you want to discard them?'),
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
child: Text(context.l10n.dialogDiscard),
),
],
),
@@ -214,7 +213,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Metadata provider priority saved')),
SnackBar(content: Text(context.l10n.snackbarMetadataProviderSaved)),
);
}
}
@@ -246,7 +245,7 @@ class _MetadataProviderItem extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
final info = _getProviderInfo(provider);
final info = _getProviderInfo(context, provider);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
@@ -323,20 +322,20 @@ class _MetadataProviderItem extends StatelessWidget {
);
}
_MetadataProviderInfo _getProviderInfo(String provider) {
_MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) {
switch (provider) {
case 'deezer':
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: 'No rate limits',
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'spotify':
return _MetadataProviderInfo(
name: 'Spotify',
icon: Icons.music_note,
description: 'May rate limit',
description: context.l10n.metadataMayRateLimit,
isBuiltIn: true,
);
default:
@@ -344,7 +343,7 @@ class _MetadataProviderItem extends StatelessWidget {
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
description: 'Extension',
description: context.l10n.providerExtension,
isBuiltIn: false,
);
}
+70 -91
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -50,7 +51,7 @@ class OptionsSettingsPage extends ConsumerWidget {
bottom: 16,
),
title: Text(
'Options',
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
@@ -63,8 +64,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Search Source section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Search Source'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -93,7 +94,7 @@ class OptionsSettingsPage extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
context.l10n.optionsSpotifyWarning,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
@@ -107,10 +108,10 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsItem(
icon: Icons.key,
title: 'Spotify Credentials',
title: context.l10n.optionsSpotifyCredentials,
subtitle: settings.spotifyClientId.isNotEmpty
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
: 'Required - tap to configure',
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId)
: context.l10n.optionsSpotifyCredentialsRequired,
onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings),
trailing: Icon(
@@ -130,16 +131,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Download options section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Download'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.sync,
title: 'Auto Fallback',
subtitle: 'Try other services if download fails',
title: context.l10n.optionsAutoFallback,
subtitle: context.l10n.optionsAutoFallbackSubtitle,
value: settings.autoFallback,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setAutoFallback(v),
@@ -147,10 +148,10 @@ class OptionsSettingsPage extends ConsumerWidget {
if (hasExtensions)
SettingsSwitchItem(
icon: Icons.extension,
title: 'Use Extension Providers',
title: context.l10n.optionsUseExtensionProviders,
subtitle: settings.useExtensionProviders
? 'Extensions will be tried first'
: 'Using built-in providers only',
? context.l10n.optionsUseExtensionProvidersOn
: context.l10n.optionsUseExtensionProvidersOff,
value: settings.useExtensionProviders,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -158,16 +159,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsSwitchItem(
icon: Icons.lyrics,
title: 'Embed Lyrics',
subtitle: 'Embed synced lyrics into FLAC files',
title: context.l10n.optionsEmbedLyrics,
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
value: settings.embedLyrics,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
),
SettingsSwitchItem(
icon: Icons.image,
title: 'Max Quality Cover',
subtitle: 'Download highest resolution cover art',
title: context.l10n.optionsMaxQualityCover,
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
value: settings.maxQualityCover,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -179,8 +180,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Performance section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Performance'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -196,16 +197,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// App section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.store,
title: 'Extension Store',
subtitle: 'Show Store tab in navigation',
title: context.l10n.optionsExtensionStore,
subtitle: context.l10n.optionsExtensionStoreSubtitle,
value: settings.showExtensionStore,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -213,8 +214,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsSwitchItem(
icon: Icons.system_update,
title: 'Check for Updates',
subtitle: 'Notify when new version is available',
title: context.l10n.optionsCheckUpdates,
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
value: settings.checkForUpdates,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -230,16 +231,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Data section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Data'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionData),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.delete_forever,
title: 'Clear Download History',
subtitle: 'Remove all downloaded tracks from history',
title: context.l10n.optionsClearHistory,
subtitle: context.l10n.optionsClearHistorySubtitle,
onTap: () =>
_showClearHistoryDialog(context, ref, colorScheme),
showDivider: false,
@@ -249,18 +250,18 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Debug section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Debug'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.bug_report,
title: 'Detailed Logging',
title: context.l10n.optionsDetailedLogging,
subtitle: settings.enableLogging
? 'Detailed logs are being recorded'
: 'Enable for bug reports',
? context.l10n.optionsDetailedLoggingOn
: context.l10n.optionsDetailedLoggingOff,
value: settings.enableLogging,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEnableLogging(v),
@@ -285,14 +286,14 @@ class OptionsSettingsPage extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text(
'Are you sure you want to clear all download history? This cannot be undone.',
title: Text(context.l10n.dialogClearHistoryTitle),
content: Text(
context.l10n.dialogClearHistoryMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
@@ -300,9 +301,9 @@ class OptionsSettingsPage extends ConsumerWidget {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('History cleared')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared)));
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
@@ -353,7 +354,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
Text(
'Spotify Credentials',
context.l10n.credentialsTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -361,7 +362,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 8),
Text(
'Enter your Client ID and Secret to use your own Spotify application quota.',
context.l10n.credentialsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -373,8 +374,8 @@ class OptionsSettingsPage extends ConsumerWidget {
TextField(
controller: clientIdController,
decoration: InputDecoration(
labelText: 'Client ID',
hintText: 'Paste Client ID',
labelText: context.l10n.credentialsClientId,
hintText: context.l10n.credentialsClientIdHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
@@ -412,8 +413,8 @@ class OptionsSettingsPage extends ConsumerWidget {
controller: clientSecretController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Client Secret',
hintText: 'Paste Client Secret',
labelText: context.l10n.credentialsClientSecret,
hintText: context.l10n.credentialsClientSecretHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
@@ -458,12 +459,12 @@ class OptionsSettingsPage extends ConsumerWidget {
.setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials saved')),
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fill all fields'),
SnackBar(
content: Text(context.l10n.snackbarFillAllFields),
),
);
}
@@ -474,9 +475,9 @@ class OptionsSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text(
'Save Credentials',
style: TextStyle(fontWeight: FontWeight.bold),
child: Text(
context.l10n.actionSaveCredentials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
@@ -489,14 +490,14 @@ class OptionsSettingsPage extends ConsumerWidget {
.clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials cleared')),
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
);
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Remove Credentials'),
child: Text(context.l10n.actionRemoveCredentials),
),
],
@@ -540,14 +541,14 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Concurrent Downloads',
context.l10n.optionsConcurrentDownloads,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
currentValue == 1
? 'Sequential (1 at a time)'
: '$currentValue parallel downloads',
? context.l10n.optionsConcurrentSequential
: context.l10n.optionsConcurrentParallel(currentValue),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -590,7 +591,7 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting',
context.l10n.optionsConcurrentWarning,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
@@ -682,14 +683,14 @@ class _UpdateChannelSelector extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Update Channel',
context.l10n.optionsUpdateChannel,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
currentChannel == 'preview'
? 'Get preview releases'
: 'Stable releases only',
? context.l10n.optionsUpdateChannelPreview
: context.l10n.optionsUpdateChannelStable,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -703,13 +704,13 @@ class _UpdateChannelSelector extends StatelessWidget {
Row(
children: [
_ChannelChip(
label: 'Stable',
label: context.l10n.channelStable,
isSelected: currentChannel == 'stable',
onTap: () => onChanged('stable'),
),
const SizedBox(width: 8),
_ChannelChip(
label: 'Preview',
label: context.l10n.channelPreview,
isSelected: currentChannel == 'preview',
onTap: () => onChanged('preview'),
),
@@ -726,7 +727,7 @@ class _UpdateChannelSelector extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Preview may contain bugs or incomplete features',
context.l10n.optionsUpdateChannelWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -823,7 +824,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Primary Provider',
context.l10n.optionsPrimaryProvider,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
@@ -831,8 +832,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(height: 4),
Text(
hasExtensionSearch
? 'Using extension: $extensionName'
: 'Service used when searching by track name.',
? context.l10n.optionsUsingExtension(extensionName!)
: context.l10n.optionsPrimaryProviderSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasExtensionSearch
? colorScheme.primary
@@ -883,7 +884,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Tap Deezer or Spotify to switch back from extension',
context.l10n.optionsSwitchBack,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -903,16 +904,12 @@ class _SourceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback? onTap;
final String? badge;
final Color? badgeColor;
const _SourceChip({
required this.icon,
required this.label,
required this.isSelected,
this.onTap,
this.badge,
this.badgeColor,
});
@override
@@ -958,24 +955,6 @@ class _SourceChip extends StatelessWidget {
: colorScheme.onSurfaceVariant,
),
),
if (badge != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
badge!,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: badgeColor ?? colorScheme.tertiary,
),
),
),
],
],
),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
@@ -82,7 +83,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
@@ -97,7 +98,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Provider Priority',
context.l10n.providerPriorityTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -114,8 +115,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder download providers. The app will try providers '
'from top to bottom when downloading tracks.',
context.l10n.providerPriorityDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -167,8 +167,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
const SizedBox(width: 12),
Expanded(
child: Text(
'If a track is not available on the first provider, '
'the app will automatically try the next one.',
context.l10n.providerPriorityInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -191,16 +190,16 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard Changes?'),
content: const Text('You have unsaved changes. Do you want to discard them?'),
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
child: Text(context.l10n.dialogDiscard),
),
],
),
@@ -215,7 +214,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Provider priority saved')),
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
);
}
}
@@ -304,7 +303,7 @@ class _ProviderItem extends StatelessWidget {
),
),
Text(
info.isBuiltIn ? 'Built-in' : 'Extension',
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
+61 -47
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
@@ -41,7 +42,7 @@ class SettingsTab extends ConsumerWidget {
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Settings',
context.l10n.settingsTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio), // 20 -> 34
fontWeight: FontWeight.bold,
@@ -55,57 +56,67 @@ class SettingsTab extends ConsumerWidget {
// First group: Appearance & Download
SliverToBoxAdapter(
child: SettingsGroup(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
children: [
SettingsItem(
icon: Icons.palette_outlined,
title: 'Appearance',
subtitle: 'Theme, colors, display',
onTap: () =>
_navigateTo(context, const AppearanceSettingsPage()),
),
SettingsItem(
icon: Icons.download_outlined,
title: 'Download',
subtitle: 'Service, quality, filename format',
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: 'Options',
subtitle: 'Fallback, lyrics, cover art, updates',
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
title: 'Extensions',
subtitle: 'Manage download providers',
onTap: () => _navigateTo(context, const ExtensionsPage()),
showDivider: false,
),
],
child: Builder(
builder: (context) {
final l10n = context.l10n;
return SettingsGroup(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
children: [
SettingsItem(
icon: Icons.palette_outlined,
title: l10n.settingsAppearance,
subtitle: l10n.settingsAppearanceSubtitle,
onTap: () =>
_navigateTo(context, const AppearanceSettingsPage()),
),
SettingsItem(
icon: Icons.download_outlined,
title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
title: l10n.settingsExtensions,
subtitle: l10n.settingsExtensionsSubtitle,
onTap: () => _navigateTo(context, const ExtensionsPage()),
showDivider: false,
),
],
);
},
),
),
// Second group: Logs & About
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.article_outlined,
title: 'Logs',
subtitle: 'View app logs for debugging',
onTap: () => _navigateTo(context, const LogScreen()),
),
SettingsItem(
icon: Icons.info_outline,
title: 'About',
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
],
child: Builder(
builder: (context) {
final l10n = context.l10n;
return SettingsGroup(
children: [
SettingsItem(
icon: Icons.article_outlined,
title: l10n.logTitle,
subtitle: l10n.settingsLogsSubtitle,
onTap: () => _navigateTo(context, const LogScreen()),
),
SettingsItem(
icon: Icons.info_outline,
title: l10n.settingsAbout,
subtitle: '${l10n.aboutVersion} ${AppInfo.version}',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
],
);
},
),
),
@@ -116,6 +127,9 @@ class SettingsTab extends ConsumerWidget {
}
void _navigateTo(BuildContext context, Widget page) {
// Unfocus any focused widget before navigating to prevent keyboard from appearing on return
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(
// Use PageRouteBuilder for better predictive back gesture support
// MaterialPageRoute can cause freeze on some devices with gesture navigation
+60 -60
View File
@@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:go_router/go_router.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key});
@@ -123,19 +124,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Access Required'),
content: const Text(
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
'Please enable "Allow access to manage all files" in the next screen.',
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(
'${context.l10n.setupStorageAccessMessage}\n\n'
'${context.l10n.setupAllowAccessToManageFiles}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Open Settings'),
child: Text(context.l10n.setupOpenSettings),
),
],
),
@@ -166,19 +167,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Access Required'),
content: const Text(
'Android 11+ requires "All files access" permission to save music files.\n\n'
'Please enable "Allow access to manage all files" in the next screen.',
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(
'${context.l10n.setupStorageAccessMessageAndroid11}\n\n'
'${context.l10n.setupAllowAccessToManageFiles}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Open Settings'),
child: Text(context.l10n.setupOpenSettings),
),
],
),
@@ -211,7 +212,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
);
}
}
@@ -256,22 +257,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('$permissionType Permission Required'),
title: Text(context.l10n.setupPermissionRequired(permissionType)),
content: Text(
'$permissionType permission is required for the best experience. '
'Please grant permission in app settings.',
context.l10n.setupPermissionRequiredMessage(permissionType),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
child: const Text('Open Settings'),
child: Text(context.l10n.setupOpenSettings),
),
],
),
@@ -288,7 +288,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} else {
// Android: Use file picker
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
dialogTitle: context.l10n.setupSelectDownloadFolder,
);
if (selectedDirectory != null) {
@@ -299,11 +299,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
title: Text(context.l10n.setupUseDefaultFolder),
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel)),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault)),
],
),
);
@@ -333,19 +333,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
child: Text(context.l10n.setupDownloadLocationTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
context.l10n.setupDownloadLocationIosMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
title: Text(context.l10n.setupAppDocumentsFolder),
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await _getDefaultDirectory();
@@ -355,8 +355,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
@@ -380,7 +380,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
context.l10n.setupIosEmptyFolderWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
@@ -486,16 +486,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Column(
children: [
const SizedBox(height: 24),
ClipRRect(
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
),
const SizedBox(height: 12),
Text('SpotiFLAC',
Text(context.l10n.appName,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, color: colorScheme.primary)),
const SizedBox(height: 4),
Text('Download Spotify tracks in FLAC',
Text(context.l10n.setupDownloadInFlac,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant)),
],
@@ -529,8 +529,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildStepIndicator(ColorScheme colorScheme) {
final steps = _androidSdkVersion >= 33
? ['Storage', 'Notification', 'Folder', 'Spotify']
: ['Permission', 'Folder', 'Spotify'];
? [context.l10n.setupStepStorage, context.l10n.setupStepNotification, context.l10n.setupStepFolder, context.l10n.setupStepSpotify]
: [context.l10n.setupStepPermission, context.l10n.setupStepFolder, context.l10n.setupStepSpotify];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -653,7 +653,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
_storagePermissionGranted ? context.l10n.setupStorageGranted : context.l10n.setupStorageRequired,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -662,8 +662,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_storagePermissionGranted
? 'You can now proceed to the next step.'
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
? context.l10n.setupProceedToNextStep
: context.l10n.setupStorageDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -676,7 +676,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.security_rounded),
label: const Text('Grant Permission'),
label: Text(context.l10n.setupGrantPermission),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -707,7 +707,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
_notificationPermissionGranted ? context.l10n.setupNotificationGranted : context.l10n.setupNotificationEnable,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -716,8 +716,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
? context.l10n.setupNotificationProgressDescription
: context.l10n.setupNotificationBackgroundDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -730,7 +730,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.notifications_active_rounded),
label: const Text('Enable Notifications'),
label: Text(context.l10n.setupEnableNotifications),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -742,7 +742,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Skip for now'),
child: Text(context.l10n.setupSkipForNow),
),
],
],
@@ -770,7 +770,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
_selectedDirectory != null ? context.l10n.setupFolderSelected : context.l10n.setupFolderChoose,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -802,7 +802,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Select a folder where your downloaded music will be saved.',
context.l10n.setupFolderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -814,7 +814,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
label: Text(_selectedDirectory != null ? context.l10n.setupChangeFolder : context.l10n.setupSelectFolder),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -845,7 +845,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
'Spotify API (Optional)',
context.l10n.setupSpotifyApiOptional,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -853,7 +853,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Add your Spotify API credentials for better search results, or skip to use Deezer instead.',
context.l10n.setupSpotifyApiDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -868,9 +868,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
clipBehavior: Clip.antiAlias,
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
title: Text('Use Spotify API', style: Theme.of(context).textTheme.titleSmall),
title: Text(context.l10n.setupUseSpotifyApi, style: Theme.of(context).textTheme.titleSmall),
subtitle: Text(
_useSpotifyApi ? 'Enter your credentials below' : 'Using Deezer (no account needed)',
_useSpotifyApi ? context.l10n.setupEnterCredentialsBelow : context.l10n.setupUsingDeezer,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
secondary: Container(
@@ -907,12 +907,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Client ID
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
controller: _clientIdController,
decoration: InputDecoration(
hintText: 'Enter Spotify Client ID',
hintText: context.l10n.setupEnterClientId,
prefixIcon: const Icon(Icons.key_rounded),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -926,13 +926,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(height: 16),
// Client Secret
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
controller: _clientSecretController,
obscureText: !_showClientSecret,
decoration: InputDecoration(
hintText: 'Enter Spotify Client Secret',
hintText: context.l10n.setupEnterClientSecret,
prefixIcon: const Icon(Icons.lock_rounded),
suffixIcon: IconButton(
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
@@ -962,7 +962,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Get credentials from developer.spotify.com',
context.l10n.setupGetCredentialsFromSpotify,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
@@ -995,7 +995,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
TextButton.icon(
onPressed: () => setState(() => _currentStep--),
icon: const Icon(Icons.arrow_back_rounded),
label: const Text('Back'),
label: Text(context.l10n.setupBack),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
@@ -1011,9 +1011,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward_rounded, size: 18)],
children: [Text(context.l10n.setupNext), const SizedBox(width: 8), const Icon(Icons.arrow_forward_rounded, size: 18)],
),
)
else
@@ -1029,7 +1029,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
Text(_useSpotifyApi ? context.l10n.setupGetStarted : context.l10n.setupSkipAndStart),
const SizedBox(width: 8),
const Icon(Icons.check_rounded, size: 18),
],
+31 -30
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -40,7 +41,7 @@ class _ExtensionDetailsScreenState
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
_buildSectionHeader(
context,
'About',
context.l10n.aboutTitle,
Icons.info_outline,
colorScheme,
),
@@ -61,7 +62,7 @@ class _ExtensionDetailsScreenState
_buildSectionHeader(
context,
'Capabilities',
context.l10n.extensionCapabilities,
Icons.extension_outlined,
colorScheme,
),
@@ -173,9 +174,9 @@ class _ExtensionDetailsScreenState
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
const SizedBox(height: 4),
Text(
'by ${ext.author}',
context.l10n.extensionsAuthor(ext.author),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: colorScheme.onSurfaceVariant),
),
@@ -204,7 +205,7 @@ class _ExtensionDetailsScreenState
),
if (ext.isInstalled)
_Badge(
label: 'Installed',
label: context.l10n.storeInstalled,
color: colorScheme.primaryContainer,
textColor: colorScheme.onPrimaryContainer,
icon: Icons.check,
@@ -226,7 +227,7 @@ class _ExtensionDetailsScreenState
FilledButton.icon(
onPressed: () => _updateExtension(ext),
icon: const Icon(Icons.update),
label: Text('Update to v${ext.version}'),
label: Text('${context.l10n.storeUpdate} v${ext.version}'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
@@ -241,7 +242,7 @@ class _ExtensionDetailsScreenState
child: OutlinedButton.icon(
onPressed: null,
icon: const Icon(Icons.check),
label: const Text('Installed'),
label: Text(context.l10n.storeInstalled),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
@@ -262,7 +263,7 @@ class _ExtensionDetailsScreenState
borderRadius: BorderRadius.circular(16),
),
),
tooltip: 'Uninstall',
tooltip: context.l10n.extensionsUninstall,
),
],
)
@@ -270,7 +271,7 @@ class _ExtensionDetailsScreenState
FilledButton.icon(
onPressed: () => _installExtension(ext),
icon: const Icon(Icons.download),
label: const Text('Install Extension'),
label: Text(context.l10n.storeInstall),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
@@ -380,19 +381,19 @@ class _ExtensionDetailsScreenState
child: Column(
children: [
_MetadataRow(
label: 'Updated',
label: context.l10n.extensionUpdated,
value: ext.updatedAt.isNotEmpty
? _formatDate(ext.updatedAt)
? _formatDate(context, ext.updatedAt)
: '-',
colorScheme: colorScheme,
),
_MetadataRow(
label: 'ID',
label: context.l10n.extensionId,
value: ext.id,
colorScheme: colorScheme,
),
_MetadataRow(
label: 'Min App Version',
label: context.l10n.extensionMinAppVersion,
value: ext.minAppVersion ?? 'Any',
colorScheme: colorScheme,
isLast: true,
@@ -428,19 +429,19 @@ class _ExtensionDetailsScreenState
children: [
_CapabilityRow(
icon: Icons.search,
label: 'Metadata Provider',
label: context.l10n.extensionMetadataProvider,
enabled: isMetadataProvider,
colorScheme: colorScheme,
),
_CapabilityRow(
icon: Icons.download,
label: 'Download Provider',
label: context.l10n.extensionDownloadProvider,
enabled: isDownloadProvider,
colorScheme: colorScheme,
),
_CapabilityRow(
icon: Icons.lyrics,
label: 'Lyrics Provider',
label: context.l10n.extensionLyricsProvider,
enabled: isLyricsProvider,
colorScheme: colorScheme,
),
@@ -458,22 +459,22 @@ class _ExtensionDetailsScreenState
);
}
String _formatDate(String dateStr) {
String _formatDate(BuildContext context, String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
return 'Today';
return context.l10n.dateToday;
} else if (diff.inDays == 1) {
return 'Yesterday';
return context.l10n.dateYesterday;
} else if (diff.inDays < 7) {
return '${diff.inDays} days ago';
return context.l10n.dateDaysAgo(diff.inDays);
} else if (diff.inDays < 30) {
return '${(diff.inDays / 7).floor()} weeks ago';
return context.l10n.dateWeeksAgo((diff.inDays / 7).floor());
} else if (diff.inDays < 365) {
return '${(diff.inDays / 30).floor()} months ago';
return context.l10n.dateMonthsAgo((diff.inDays / 30).floor());
} else {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
@@ -530,8 +531,8 @@ class _ExtensionDetailsScreenState
SnackBar(
content: Text(
success
? '${ext.displayName} installed.'
: 'Failed to install ${ext.displayName}',
? context.l10n.snackbarExtensionInstalled(ext.displayName)
: context.l10n.snackbarFailedToInstall,
),
behavior: SnackBarBehavior.floating,
),
@@ -551,8 +552,8 @@ class _ExtensionDetailsScreenState
SnackBar(
content: Text(
success
? '${ext.displayName} updated.'
: 'Failed to update ${ext.displayName}',
? context.l10n.snackbarExtensionUpdated(ext.displayName)
: context.l10n.snackbarFailedToUpdate,
),
behavior: SnackBarBehavior.floating,
),
@@ -564,17 +565,17 @@ class _ExtensionDetailsScreenState
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Uninstall Extension?'),
content: Text('Are you sure you want to remove ${ext.displayName}?'),
title: Text(context.l10n.dialogUninstallExtension),
content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Uninstall',
context.l10n.dialogUninstall,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
+47 -20
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
@@ -74,7 +75,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Store',
context.l10n.storeTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
@@ -93,7 +94,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search extensions...',
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
@@ -141,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
child: Row(
children: [
_CategoryChip(
label: 'All',
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: state.selectedCategory == null,
onTap: () =>
@@ -149,7 +150,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Metadata',
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected:
state.selectedCategory == StoreCategory.metadata,
@@ -159,7 +160,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Download',
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected:
state.selectedCategory == StoreCategory.download,
@@ -169,7 +170,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Utility',
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected:
state.selectedCategory == StoreCategory.utility,
@@ -179,7 +180,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Lyrics',
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected:
state.selectedCategory == StoreCategory.lyrics,
@@ -189,7 +190,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Integration',
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected:
state.selectedCategory == StoreCategory.integration,
@@ -286,7 +287,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
onPressed: () =>
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
label: Text(context.l10n.dialogRetry),
),
],
),
@@ -321,7 +322,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_searchController.clear();
ref.read(storeProvider.notifier).clearSearch();
},
child: const Text('Clear filters'),
child: Text(context.l10n.storeClearFilters),
),
],
],
@@ -547,15 +548,41 @@ class _ExtensionItem extends StatelessWidget {
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
extension.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
// Warning badge for incompatible extensions
if (extension.requiresNewerApp) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer),
const SizedBox(width: 4),
Text(
'Requires v${extension.minAppVersion}+',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
),
],
),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
] else ...[
const SizedBox(height: 4),
Text(
extension.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
@@ -574,7 +601,7 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: const Text('Update'),
child: Text(context.l10n.storeUpdate),
)
else if (extension.isInstalled)
OutlinedButton(
@@ -602,7 +629,7 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: const Text('Install'),
child: Text(context.l10n.storeInstall),
),
],
),
+51 -42
View File
@@ -4,10 +4,12 @@ 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/utils/mime_utils.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
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
@@ -27,6 +29,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _lyricsLoading = false;
String? _lyricsError;
String? _normalizeOptionalString(String? value) {
if (value == null) return null;
final trimmed = value.trim();
if (trimmed.isEmpty) return null;
if (trimmed.toLowerCase() == 'null') return null;
return trimmed;
}
@override
void initState() {
super.initState();
@@ -68,7 +78,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get trackName => item.trackName;
String get artistName => item.artistName;
String get albumName => item.albumName;
String? get albumArtist => item.albumArtist;
String? get albumArtist => _normalizeOptionalString(item.albumArtist);
int? get trackNumber => item.trackNumber;
int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate;
@@ -316,7 +326,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 6),
Text(
'File not found',
context.l10n.trackFileNotFound,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -352,7 +362,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 8),
Text(
'Metadata',
context.l10n.trackMetadata,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -374,7 +384,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return OutlinedButton.icon(
onPressed: () => _openServiceUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
label: Text(isDeezer ? context.l10n.trackOpenInDeezer : context.l10n.trackOpenInSpotify),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(
@@ -431,7 +441,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (context.mounted) {
_copyToClipboard(context, webUrl);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
SnackBar(content: Text(context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'))),
);
}
}
@@ -447,21 +457,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
final items = <_MetadataItem>[
_MetadataItem('Track name', trackName),
_MetadataItem('Artist', artistName),
_MetadataItem(context.l10n.trackTrackName, trackName),
_MetadataItem(context.l10n.trackArtist, artistName),
if (albumArtist != null && albumArtist != artistName)
_MetadataItem('Album artist', albumArtist!),
_MetadataItem('Album', albumName),
_MetadataItem(context.l10n.trackAlbumArtist, albumArtist!),
_MetadataItem(context.l10n.trackAlbum, albumName),
if (trackNumber != null && trackNumber! > 0)
_MetadataItem('Track number', trackNumber.toString()),
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
if (discNumber != null && discNumber! > 0)
_MetadataItem('Disc number', discNumber.toString()),
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)),
_MetadataItem(context.l10n.trackDuration, _formatDuration(item.duration!)),
if (audioQualityStr != null)
_MetadataItem('Audio quality', audioQualityStr),
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem('Release date', releaseDate!),
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', isrc!),
];
@@ -473,8 +483,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
items.addAll([
_MetadataItem('Service', item.service.toUpperCase()),
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
_MetadataItem(context.l10n.trackMetadataService, item.service.toUpperCase()),
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(item.downloadedAt)),
]);
return Column(
@@ -548,7 +558,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 8),
Text(
'File Info',
context.l10n.trackFileInfo,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -699,7 +709,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 8),
Text(
'Lyrics',
context.l10n.trackLyrics,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -710,7 +720,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () => _copyToClipboard(context, _lyrics!),
tooltip: 'Copy lyrics',
tooltip: context.l10n.trackCopyLyrics,
),
],
),
@@ -742,7 +752,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
TextButton(
onPressed: _fetchLyrics,
child: const Text('Retry'),
child: Text(context.l10n.dialogRetry),
),
],
),
@@ -765,7 +775,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: FilledButton.tonalIcon(
onPressed: _fetchLyrics,
icon: const Icon(Icons.download),
label: const Text('Load Lyrics'),
label: Text(context.l10n.trackLoadLyrics),
),
),
],
@@ -797,7 +807,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (mounted) {
if (result.isEmpty) {
setState(() {
_lyricsError = 'Lyrics not available for this track';
_lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false;
});
} else {
@@ -812,8 +822,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (e) {
if (mounted) {
final errorMsg = e.toString().contains('TimeoutException')
? 'Request timed out. Try again later.'
: 'Failed to load lyrics';
? context.l10n.trackLyricsTimeout
: context.l10n.trackLyricsLoadFailed;
setState(() {
_lyricsError = errorMsg;
_lyricsLoading = false;
@@ -847,7 +857,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: FilledButton.icon(
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
label: Text(context.l10n.trackMetadataPlay),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@@ -863,7 +873,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: OutlinedButton.icon(
onPressed: () => _confirmDelete(context, ref, colorScheme),
icon: Icon(Icons.delete_outline, color: colorScheme.error),
label: Text('Delete', style: TextStyle(color: colorScheme.error)),
label: Text(context.l10n.trackMetadataDelete, style: TextStyle(color: colorScheme.error)),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@@ -899,7 +909,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy file path'),
title: Text(context.l10n.trackCopyFilePath),
onTap: () {
Navigator.pop(context);
_copyToClipboard(context, cleanFilePath);
@@ -907,7 +917,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share'),
title: Text(context.l10n.trackMetadataShare),
onTap: () {
Navigator.pop(context);
_shareFile(context);
@@ -915,7 +925,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
ListTile(
leading: Icon(Icons.delete, color: colorScheme.error),
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
title: Text(context.l10n.trackRemoveFromDevice, style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, ref, colorScheme);
@@ -932,14 +942,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove from device?'),
content: const Text(
'This will permanently delete the downloaded file and remove it from your history.',
),
title: Text(context.l10n.trackDeleteConfirmTitle),
content: Text(context.l10n.trackDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () async {
@@ -961,7 +969,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Navigator.pop(context); // Go back to history
}
},
child: Text('Delete', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)),
),
],
),
@@ -970,16 +978,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Future<void> _openFile(BuildContext context, String filePath) async {
try {
final result = await OpenFilex.open(filePath);
final mimeType = audioMimeTypeForPath(filePath);
final result = await OpenFilex.open(filePath, type: mimeType);
if (result.type != ResultType.done && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open: ${result.message}')),
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
@@ -988,9 +997,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _copyToClipboard(BuildContext context, String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied to clipboard'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.trackCopiedToClipboard),
duration: const Duration(seconds: 2),
),
);
}
@@ -1000,7 +1009,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!await file.exists()) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('File not found')),
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
);
}
return;
+59
View File
@@ -199,6 +199,11 @@ class PlatformBridge {
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
}
/// Cancel an in-progress download
static Future<void> cancelDownload(String itemId) async {
await _channel.invokeMethod('cancelDownload', {'item_id': itemId});
}
/// Set download directory
static Future<void> setDownloadDirectory(String path) async {
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
@@ -787,6 +792,60 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Get album tracks using an extension
static Future<Map<String, dynamic>?> getAlbumWithExtension(
String extensionId,
String albumId,
) async {
try {
final result = await _channel.invokeMethod('getAlbumWithExtension', {
'extension_id': extensionId,
'album_id': albumId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getAlbumWithExtension failed: $e');
return null;
}
}
/// Get playlist tracks using an extension
static Future<Map<String, dynamic>?> getPlaylistWithExtension(
String extensionId,
String playlistId,
) async {
try {
final result = await _channel.invokeMethod('getPlaylistWithExtension', {
'extension_id': extensionId,
'playlist_id': playlistId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getPlaylistWithExtension failed: $e');
return null;
}
}
/// Get artist info and albums using an extension
static Future<Map<String, dynamic>?> getArtistWithExtension(
String extensionId,
String artistId,
) async {
try {
final result = await _channel.invokeMethod('getArtistWithExtension', {
'extension_id': extensionId,
'artist_id': artistId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getArtistWithExtension failed: $e');
return null;
}
}
// ==================== EXTENSION POST-PROCESSING ====================
/// Run post-processing hooks on a file
+24
View File
@@ -0,0 +1,24 @@
String audioMimeTypeForPath(String filePath) {
final dotIndex = filePath.lastIndexOf('.');
if (dotIndex == -1 || dotIndex == filePath.length - 1) {
return 'audio/*';
}
final ext = filePath.substring(dotIndex + 1).toLowerCase();
switch (ext) {
case 'flac':
return 'audio/flac';
case 'm4a':
return 'audio/mp4';
case 'mp3':
return 'audio/mpeg';
case 'ogg':
return 'audio/ogg';
case 'wav':
return 'audio/wav';
case 'aac':
return 'audio/aac';
default:
return 'audio/*';
}
}
+4 -3
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Built-in service info with quality options
class BuiltInService {
@@ -167,7 +168,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Download From',
context.l10n.downloadFrom,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -202,7 +203,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Select Quality',
context.l10n.downloadSelectQuality,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -212,7 +213,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
context.l10n.qualityNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
+14 -13
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/services/apk_downloader.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class UpdateDialog extends StatefulWidget {
final UpdateInfo updateInfo;
@@ -42,7 +43,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
setState(() {
_isDownloading = true;
_progress = 0;
_statusText = 'Starting download...';
_statusText = context.l10n.updateStartingDownload;
});
final notificationService = NotificationService();
@@ -91,11 +92,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
if (mounted) {
setState(() {
_isDownloading = false;
_statusText = 'Download failed';
_statusText = context.l10n.updateDownloadFailed;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to download update')),
SnackBar(content: Text(context.l10n.updateFailedMessage)),
);
}
}
@@ -131,9 +132,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
Text(context.l10n.updateAvailable, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(context.l10n.updateNewVersionReady, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
),
@@ -154,11 +155,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
_VersionChip(version: AppInfo.version, label: context.l10n.updateCurrent, colorScheme: colorScheme),
const SizedBox(width: 12),
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 12),
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
_VersionChip(version: widget.updateInfo.version, label: context.l10n.updateNew, colorScheme: colorScheme, isNew: true),
],
),
),
@@ -184,7 +185,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
),
const SizedBox(width: 12),
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
Text(context.l10n.updateDownloading, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
@@ -209,7 +210,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
] else ...[
// Changelog section
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 180),
@@ -240,7 +241,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
)
else
@@ -251,7 +252,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: FilledButton.icon(
onPressed: _downloadAndInstall,
icon: const Icon(Icons.download_rounded, size: 20),
label: const Text('Download & Install'),
label: Text(context.l10n.updateDownloadInstall),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -271,7 +272,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
child: Text(context.l10n.updateDontRemind, style: TextStyle(color: colorScheme.onSurfaceVariant)),
),
),
const SizedBox(width: 8),
@@ -285,7 +286,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Later'),
child: Text(context.l10n.updateLater),
),
),
],
+13
View File
@@ -382,6 +382,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -488,6 +493,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.7.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
+9 -3
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0+57
version: 3.1.0+59
environment:
sdk: ^3.10.0
@@ -10,6 +10,11 @@ dependencies:
flutter:
sdk: flutter
# Localization
flutter_localizations:
sdk: flutter
intl: any
# State Management
flutter_riverpod: ^3.1.0
riverpod_annotation: ^4.0.0
@@ -22,7 +27,7 @@ dependencies:
path_provider: ^2.1.5
# HTTP & Network
http: ^1.4.0
http: ^1.6.0
dio: ^5.8.0
# UI Components
@@ -38,7 +43,7 @@ dependencies:
permission_handler: ^12.0.1
# File Picker
file_picker: ^10.3.0
file_picker: ^10.3.8
# JSON Serialization
json_annotation: ^4.9.0
@@ -77,6 +82,7 @@ flutter_launcher_icons:
flutter:
uses-material-design: true
generate: true
assets:
- assets/images/
+9 -3
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0+57
version: 3.1.0+59
environment:
sdk: ^3.10.0
@@ -10,6 +10,11 @@ dependencies:
flutter:
sdk: flutter
# Localization
flutter_localizations:
sdk: flutter
intl: any
# State Management
flutter_riverpod: ^3.1.0
riverpod_annotation: ^4.0.0
@@ -22,7 +27,7 @@ dependencies:
path_provider: ^2.1.5
# HTTP & Network
http: ^1.4.0
http: ^1.6.0
dio: ^5.8.0
# UI Components
@@ -38,7 +43,7 @@ dependencies:
permission_handler: ^12.0.1
# File Picker
file_picker: ^10.3.0
file_picker: ^10.3.8
# JSON Serialization
json_annotation: ^4.9.0
@@ -77,6 +82,7 @@ flutter_launcher_icons:
flutter:
uses-material-design: true
generate: true
assets:
- assets/images/