Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be9444c76b | |||
| cedb32904e | |||
| e73f932083 | |||
| 4645d3ac8b | |||
| 1cdf8b7f23 | |||
| 1e18f53e6a | |||
| fc8cfb05d0 | |||
| fc0c0571fe | |||
| e6ca29e199 | |||
| 7413a8a698 | |||
| 205032e094 | |||
| 9c6f438e22 | |||
| 4f2587554a | |||
| 369fdd84bf | |||
| 5c3b668e92 | |||
| 141db45051 | |||
| 8f9bc8f058 | |||
| be372604fe | |||
| 6c25fc6a8d | |||
| 2eef021587 | |||
| 9eac6e6e56 | |||
| e5c310f455 | |||
| d8f73dfa56 | |||
| f128d0caf0 | |||
| aa499ceba2 | |||
| 01306afc2d | |||
| 9a3cd0273b | |||
| ac25683f33 | |||
| 624b2112d8 | |||
| 8bd34dc87e | |||
| 948779bcfc | |||
| a74b3a19f7 | |||
| 931d9fbf61 | |||
| a8c76004db | |||
| 0df4596f79 | |||
| cf549df049 | |||
| bd3783154b | |||
| 6919408905 | |||
| f4c08a5981 | |||
| 7fff55da96 | |||
| 3c4dbd1a80 | |||
| f26af38c1e | |||
| 7c6705c75c | |||
| b193bc0b8f | |||
| 1a90887465 | |||
| 82440affac | |||
| 6d2f75c5dc |
@@ -59,3 +59,14 @@ extension/
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||
[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
|
||||
[](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).
|
||||
|
||||
[](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") ?: ""
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,3 @@
|
||||
files:
|
||||
- source: /lib/l10n/arb/app_en.arb
|
||||
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||