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
|
# Agent instructions
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
|
# Temp/misc
|
||||||
|
nul
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
hs_err_*.log
|
||||||
|
flutter_*.log
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
tool/
|
||||||
|
|||||||
@@ -1,5 +1,138 @@
|
|||||||
# Changelog
|
# 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
|
## [3.0.0] - 2026-01-14
|
||||||
|
|
||||||
### Extension System (Major Feature)
|
### 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
|
- Based on `album_type` from Spotify/Deezer metadata
|
||||||
- Toggle in Settings > Download > Separate Singles Folder
|
- 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
|
- **Parallel API Calls**: Download URL fetching now uses parallel requests
|
||||||
- Tidal: All 8 APIs requested simultaneously, first success wins
|
- Tidal: All 8 APIs requested simultaneously, first success wins
|
||||||
- Qobuz: Both 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://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">
|
<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" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Metadata Source
|
## Search Source
|
||||||
|
|
||||||
SpotiFLAC supports two metadata sources for searching tracks:
|
SpotiFLAC supports two search sources:
|
||||||
|
|
||||||
| Source | Pros | Cons |
|
| Source | Setup |
|
||||||
|--------|------|------|
|
|--------|-------|
|
||||||
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
| **Deezer** (Default) | No setup required |
|
||||||
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## Extensions
|
## 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)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
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)
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"cancelDownload" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.cancelDownload(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"setDownloadDirectory" -> {
|
"setDownloadDirectory" -> {
|
||||||
val path = call.argument<String>("path") ?: ""
|
val path = call.argument<String>("path") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -572,6 +579,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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
|
// Extension Post-Processing API
|
||||||
"runPostProcessing" -> {
|
"runPostProcessing" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
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
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -346,13 +348,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
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)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -400,6 +413,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
// Check for any errors
|
// Check for any errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -527,6 +543,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
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)
|
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"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
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 {
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
artistName := track.Artist.Name
|
artistName := track.Artist.Name
|
||||||
if len(track.Contributors) > 0 {
|
if len(track.Contributors) > 0 {
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||||||
return path, exists
|
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)
|
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||||
// Returns filepath if found, empty string if not found
|
// Returns filepath if found, empty string if not found
|
||||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
@@ -138,7 +150,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
|
|
||||||
// Use index for fast lookup
|
// Use index for fast lookup
|
||||||
idx := GetISRCIndex(outputDir)
|
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)
|
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseSpotifyURL parses and validates a Spotify URL
|
// ParseSpotifyURL parses and validates a Spotify URL
|
||||||
@@ -150,6 +153,10 @@ type DownloadRequest struct {
|
|||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
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
|
// DownloadResponse represents the result of a download
|
||||||
@@ -399,7 +406,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
DiscNumber: tidalResult.DiscNumber,
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
ISRC: tidalResult.ISRC,
|
ISRC: tidalResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
@@ -418,7 +425,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
ISRC: qobuzResult.ISRC,
|
ISRC: qobuzResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -437,12 +444,16 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return errorResponse("Download cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
@@ -536,6 +547,11 @@ func ClearItemProgress(itemID string) {
|
|||||||
RemoveItemProgress(itemID)
|
RemoveItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelDownload cancels an in-progress download for the given item.
|
||||||
|
func CancelDownload(itemID string) {
|
||||||
|
cancelDownload(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
// CleanupConnections closes idle HTTP connections
|
// CleanupConnections closes idle HTTP connections
|
||||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||||
func CleanupConnections() {
|
func CleanupConnections() {
|
||||||
@@ -1025,6 +1041,8 @@ func errorResponse(msg string) (string, error) {
|
|||||||
strings.Contains(lowerMsg, "try using vpn") ||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
strings.Contains(lowerMsg, "change dns") {
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
errorType = "isp_blocked"
|
errorType = "isp_blocked"
|
||||||
|
} else if strings.Contains(lowerMsg, "cancel") {
|
||||||
|
errorType = "cancelled"
|
||||||
} else if strings.Contains(lowerMsg, "permission") ||
|
} else if strings.Contains(lowerMsg, "permission") ||
|
||||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||||
strings.Contains(lowerMsg, "access denied") ||
|
strings.Contains(lowerMsg, "access denied") ||
|
||||||
@@ -1516,6 +1534,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"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,
|
"disc_number": track.DiscNumber,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
|
"item_type": track.ItemType,
|
||||||
|
"album_type": track.AlbumType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response["tracks"] = tracks
|
response["tracks"] = tracks
|
||||||
@@ -1627,15 +1649,20 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"cover_url": result.Album.CoverURL,
|
"cover_url": result.Album.CoverURL,
|
||||||
"release_date": result.Album.ReleaseDate,
|
"release_date": result.Album.ReleaseDate,
|
||||||
"total_tracks": result.Album.TotalTracks,
|
"total_tracks": result.Album.TotalTracks,
|
||||||
|
"album_type": result.Album.AlbumType,
|
||||||
|
"provider_id": result.Album.ProviderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add artist info if present
|
// Add artist info if present
|
||||||
if result.Artist != nil {
|
if result.Artist != nil {
|
||||||
artistResponse := map[string]interface{}{
|
artistResponse := map[string]interface{}{
|
||||||
"id": result.Artist.ID,
|
"id": result.Artist.ID,
|
||||||
"name": result.Artist.Name,
|
"name": result.Artist.Name,
|
||||||
"image_url": result.Artist.ImageURL,
|
"image_url": result.Artist.ImageURL,
|
||||||
|
"header_image": result.Artist.HeaderImage,
|
||||||
|
"listeners": result.Artist.Listeners,
|
||||||
|
"provider_id": result.Artist.ProviderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add albums if present
|
// Add albums if present
|
||||||
@@ -1651,14 +1678,39 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"name": album.Name,
|
"name": album.Name,
|
||||||
"artists": album.Artists,
|
"artists": album.Artists,
|
||||||
"images": album.CoverURL,
|
"images": album.CoverURL,
|
||||||
|
"cover_url": album.CoverURL,
|
||||||
"release_date": album.ReleaseDate,
|
"release_date": album.ReleaseDate,
|
||||||
"total_tracks": album.TotalTracks,
|
"total_tracks": album.TotalTracks,
|
||||||
"album_type": albumType,
|
"album_type": albumType,
|
||||||
|
"provider_id": album.ProviderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
artistResponse["albums"] = albums
|
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
|
response["artist"] = artistResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1681,6 +1733,259 @@ func FindURLHandlerJSON(url string) string {
|
|||||||
return handler.extension.ID
|
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
|
// GetURLHandlersJSON returns all extensions that handle custom URLs
|
||||||
func GetURLHandlersJSON() (string, error) {
|
func GetURLHandlersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -29,6 +30,14 @@ type ExtTrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
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
|
// 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
|
// ExtArtistMetadata represents artist metadata from an extension
|
||||||
type ExtArtistMetadata struct {
|
type ExtArtistMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
|
||||||
ProviderID string `json:"provider_id"`
|
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
|
// 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)
|
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||||
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
|
// Can also update other fields if needed
|
||||||
if enrichedTrack.Name != "" {
|
if enrichedTrack.Name != "" {
|
||||||
req.TrackName = enrichedTrack.Name
|
req.TrackName = enrichedTrack.Name
|
||||||
@@ -814,6 +839,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download cancelled",
|
||||||
|
ErrorType: "cancelled",
|
||||||
|
Service: req.Source,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||||
@@ -858,6 +891,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download cancelled",
|
||||||
|
ErrorType: "cancelled",
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||||
}
|
}
|
||||||
@@ -943,6 +984,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download cancelled",
|
||||||
|
ErrorType: "cancelled",
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", 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 {
|
for i := range handleResult.Tracks {
|
||||||
handleResult.Tracks[i].ProviderID = p.extension.ID
|
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
|
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
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
|
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||||
|
return 0, ErrDownloadCancelled
|
||||||
|
}
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -367,6 +369,35 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
return globalQobuzDownloader
|
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
|
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||||
// Uses same APIs as PC version for compatibility
|
// Uses same APIs as PC version for compatibility
|
||||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
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
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -887,6 +929,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
// Check for any errors
|
// Check for any errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -936,8 +981,23 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
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
|
// 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 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
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
|
// 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
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
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)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -886,29 +888,45 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with progress tracking
|
// DownloadFile downloads a file from URL with progress tracking
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Handle manifest-based download (DASH/BTS)
|
// Handle manifest-based download (DASH/BTS)
|
||||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||||
// Initialize progress tracking for manifest downloads
|
// Initialize progress tracking for manifest downloads
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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
|
// Initialize item progress for direct downloads
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -948,6 +966,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
// Check for any errors
|
// Check for any errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -968,7 +989,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return nil
|
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...")
|
fmt.Println("[Tidal] Parsing manifest...")
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
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))])
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
// 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 {
|
if err != nil {
|
||||||
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to create request: %w", 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)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] BTS download failed: %v\n", err)
|
GoLog("[Tidal] BTS download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download file: %w", 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 {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
@@ -1062,10 +1093,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
// Download initialization segment
|
// Download initialization segment
|
||||||
GoLog("[Tidal] Downloading init segment...\n")
|
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 {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
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)
|
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download init segment: %w", 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 {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to write init segment: %w", 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
|
// Download media segments with progress
|
||||||
totalSegments := len(mediaURLs)
|
totalSegments := len(mediaURLs)
|
||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(m4aPath)
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
if i%10 == 0 || i == totalSegments-1 {
|
if i%10 == 0 || i == totalSegments-1 {
|
||||||
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
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)
|
SetItemProgress(itemID, progress, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Get(mediaURL)
|
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
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)
|
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to download segment %d: %w", 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 {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to write segment %d: %w", 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 track *TidalTrack
|
||||||
var err error
|
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
|
// 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 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||||
track, err = downloader.GetTrackInfoByID(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 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)
|
GoLog("[Tidal] Download failed with error: %v\n", err)
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearItemProgress(itemId)
|
GobackendClearItemProgress(itemId)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "cancelDownload":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendCancelDownload(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
@@ -503,6 +509,30 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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
|
// Extension Post-Processing API
|
||||||
case "runPostProcessing":
|
case "runPostProcessing":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<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>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>SpotiFLAC</string>
|
<string>SpotiFLAC</string>
|
||||||
<key>CFBundleExecutable</key>
|
<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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotiflac_android/screens/main_shell.dart';
|
import 'package:spotiflac_android/screens/main_shell.dart';
|
||||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||||
@@ -31,6 +33,13 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final router = ref.watch(_routerProvider);
|
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(
|
return DynamicColorWrapper(
|
||||||
builder: (lightTheme, darkTheme, themeMode) {
|
builder: (lightTheme, darkTheme, themeMode) {
|
||||||
@@ -43,6 +52,15 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
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
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.0.0';
|
static const String version = '3.1.0';
|
||||||
static const String buildNumber = '57';
|
static const String buildNumber = '59';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
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 bool useExtensionProviders; // Use extension providers for downloads when available
|
||||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
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 bool showExtensionStore; // Show Extension Store tab in navigation
|
||||||
|
final String locale; // App language: 'system', 'en', 'id', etc.
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -58,6 +59,7 @@ class AppSettings {
|
|||||||
this.separateSingles = false, // Default: disabled
|
this.separateSingles = false, // Default: disabled
|
||||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||||
this.showExtensionStore = true, // Default: show store
|
this.showExtensionStore = true, // Default: show store
|
||||||
|
this.locale = 'system', // Default: follow system language
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -88,6 +90,7 @@ class AppSettings {
|
|||||||
bool? separateSingles,
|
bool? separateSingles,
|
||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
|
String? locale,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -116,6 +119,7 @@ class AppSettings {
|
|||||||
separateSingles: separateSingles ?? this.separateSingles,
|
separateSingles: separateSingles ?? this.separateSingles,
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
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,
|
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||||
searchProvider: json['searchProvider'] as String?,
|
searchProvider: json['searchProvider'] as String?,
|
||||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
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,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
|
locale: json['locale'] as String? ?? 'system',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -64,4 +66,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'separateSingles': instance.separateSingles,
|
'separateSingles': instance.separateSingles,
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
|
'locale': instance.locale,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class Track {
|
|||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
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? albumType; // album, single, ep, compilation (from metadata API)
|
||||||
|
final String? itemType; // track, album, playlist - for extension search results
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -37,11 +38,24 @@ class Track {
|
|||||||
this.availability,
|
this.availability,
|
||||||
this.source,
|
this.source,
|
||||||
this.albumType,
|
this.albumType,
|
||||||
|
this.itemType,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Check if this track is a single (based on album_type metadata)
|
/// Check if this track is a single (based on album_type metadata)
|
||||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
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);
|
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
),
|
),
|
||||||
source: json['source'] as String?,
|
source: json['source'] as String?,
|
||||||
albumType: json['albumType'] as String?,
|
albumType: json['albumType'] as String?,
|
||||||
|
itemType: json['itemType'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
'source': instance.source,
|
'source': instance.source,
|
||||||
'albumType': instance.albumType,
|
'albumType': instance.albumType,
|
||||||
|
'itemType': instance.itemType,
|
||||||
};
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
final _log = AppLogger('DownloadQueue');
|
final _log = AppLogger('DownloadQueue');
|
||||||
final _historyLog = AppLogger('DownloadHistory');
|
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
|
// Download History Item model
|
||||||
class DownloadHistoryItem {
|
class DownloadHistoryItem {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -89,7 +97,7 @@ class DownloadHistoryItem {
|
|||||||
trackName: json['trackName'] as String,
|
trackName: json['trackName'] as String,
|
||||||
artistName: json['artistName'] as String,
|
artistName: json['artistName'] as String,
|
||||||
albumName: json['albumName'] as String,
|
albumName: json['albumName'] as String,
|
||||||
albumArtist: json['albumArtist'] as String?,
|
albumArtist: _normalizeOptionalString(json['albumArtist'] as String?),
|
||||||
coverUrl: json['coverUrl'] as String?,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
filePath: json['filePath'] as String,
|
filePath: json['filePath'] as String,
|
||||||
service: json['service'] as String,
|
service: json['service'] as String,
|
||||||
@@ -492,6 +500,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
for (final entry in items.entries) {
|
for (final entry in items.entries) {
|
||||||
final itemId = entry.key;
|
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 itemProgress = entry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = itemProgress['bytes_total'] 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
|
/// 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 {
|
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
|
||||||
String baseDir = state.outputDir;
|
String baseDir = state.outputDir;
|
||||||
|
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||||
|
|
||||||
// If separateSingles is enabled, use Albums/Singles structure
|
// If separateSingles is enabled, use Albums/Singles structure
|
||||||
if (separateSingles) {
|
if (separateSingles) {
|
||||||
@@ -688,15 +711,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} else {
|
} else {
|
||||||
// Albums folder structure based on setting
|
// Albums folder structure based on setting
|
||||||
final albumName = _sanitizeFolderName(track.albumName);
|
final albumName = _sanitizeFolderName(track.albumName);
|
||||||
|
final artistName = _sanitizeFolderName(albumArtist);
|
||||||
|
final year = _extractYear(track.releaseDate);
|
||||||
String albumPath;
|
String albumPath;
|
||||||
|
|
||||||
if (albumFolderStructure == 'album_only') {
|
switch (albumFolderStructure) {
|
||||||
// Albums/Album structure (no artist folder)
|
case 'album_only':
|
||||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
// Albums/Album structure (no artist folder)
|
||||||
} else {
|
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||||
// Albums/Artist/Album structure (default)
|
break;
|
||||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
case 'artist_year_album':
|
||||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
// 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);
|
final dir = Directory(albumPath);
|
||||||
@@ -716,7 +752,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String subPath = '';
|
String subPath = '';
|
||||||
switch (folderOrganization) {
|
switch (folderOrganization) {
|
||||||
case 'artist':
|
case 'artist':
|
||||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
final artistName = _sanitizeFolderName(albumArtist);
|
||||||
subPath = artistName;
|
subPath = artistName;
|
||||||
break;
|
break;
|
||||||
case 'album':
|
case 'album':
|
||||||
@@ -724,7 +760,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
subPath = albumName;
|
subPath = albumName;
|
||||||
break;
|
break;
|
||||||
case 'artist_album':
|
case 'artist_album':
|
||||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
final artistName = _sanitizeFolderName(albumArtist);
|
||||||
final albumName = _sanitizeFolderName(track.albumName);
|
final albumName = _sanitizeFolderName(track.albumName);
|
||||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||||
break;
|
break;
|
||||||
@@ -751,6 +787,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
.trim();
|
.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) {
|
void updateSettings(AppSettings settings) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
outputDir: settings.downloadDirectory.isNotEmpty
|
outputDir: settings.downloadDirectory.isNotEmpty
|
||||||
@@ -853,6 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(String id, double progress, {double? speedMBps}) {
|
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(
|
updateItemStatus(
|
||||||
id,
|
id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.downloading,
|
||||||
@@ -863,6 +914,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
void cancelItem(String id) {
|
void cancelItem(String id) {
|
||||||
updateItemStatus(id, DownloadStatus.skipped);
|
updateItemStatus(id, DownloadStatus.skipped);
|
||||||
|
PlatformBridge.cancelDownload(id).catchError((_) {});
|
||||||
|
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearCompleted() {
|
void clearCompleted() {
|
||||||
@@ -981,7 +1034,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
'title': track.name,
|
'title': track.name,
|
||||||
'artist': track.artistName,
|
'artist': track.artistName,
|
||||||
'album': track.albumName,
|
'album': track.albumName,
|
||||||
'album_artist': track.albumArtist ?? track.artistName,
|
'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName,
|
||||||
'track_number': track.trackNumber ?? 1,
|
'track_number': track.trackNumber ?? 1,
|
||||||
'disc_number': track.discNumber ?? 1,
|
'disc_number': track.discNumber ?? 1,
|
||||||
'isrc': track.isrc ?? '',
|
'isrc': track.isrc ?? '',
|
||||||
@@ -1084,9 +1137,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
'ALBUM': track.albumName,
|
'ALBUM': track.albumName,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (track.albumArtist != null) {
|
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
|
||||||
metadata['ALBUMARTIST'] = track.albumArtist!;
|
track.artistName;
|
||||||
}
|
metadata['ALBUMARTIST'] = albumArtist;
|
||||||
|
|
||||||
if (track.trackNumber != null) {
|
if (track.trackNumber != null) {
|
||||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
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('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
_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
|
// Set currentDownload for UI reference
|
||||||
state = state.copyWith(currentDownload: item);
|
state = state.copyWith(currentDownload: item);
|
||||||
|
|
||||||
@@ -1440,6 +1502,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final data = trackData;
|
final data = trackData;
|
||||||
_log.d('Track data keys: ${data.keys.toList()}');
|
_log.d('Track data keys: ${data.keys.toList()}');
|
||||||
_log.d('ISRC from API: ${data['isrc']}');
|
_log.d('ISRC from API: ${data['isrc']}');
|
||||||
|
_log.d('album_type from API: ${data['album_type']}');
|
||||||
trackToDownload = Track(
|
trackToDownload = Track(
|
||||||
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
||||||
name: (data['name'] as String?) ?? trackToDownload.name,
|
name: (data['name'] as String?) ?? trackToDownload.name,
|
||||||
@@ -1461,9 +1524,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
deezerId: rawId,
|
deezerId: rawId,
|
||||||
availability: trackToDownload.availability,
|
availability: trackToDownload.availability,
|
||||||
|
// Preserve albumType from API response or original track
|
||||||
|
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
|
||||||
|
source: trackToDownload.source,
|
||||||
);
|
);
|
||||||
_log.d(
|
_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 {
|
} else {
|
||||||
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
_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 cover URL for debugging CSV import issues
|
||||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||||
|
|
||||||
|
final normalizedAlbumArtist =
|
||||||
|
_normalizeOptionalString(trackToDownload.albumArtist);
|
||||||
|
|
||||||
final outputDir = await _buildOutputDir(
|
final outputDir = await _buildOutputDir(
|
||||||
trackToDownload,
|
trackToDownload,
|
||||||
settings.folderOrganization,
|
settings.folderOrganization,
|
||||||
@@ -1510,7 +1579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
trackName: trackToDownload.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: trackToDownload.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: trackToDownload.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: trackToDownload.albumArtist,
|
albumArtist: normalizedAlbumArtist,
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
@@ -1534,7 +1603,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
trackName: trackToDownload.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: trackToDownload.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: trackToDownload.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: trackToDownload.albumArtist,
|
albumArtist: normalizedAlbumArtist,
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
@@ -1555,7 +1624,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
trackName: trackToDownload.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: trackToDownload.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: trackToDownload.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: trackToDownload.albumArtist,
|
albumArtist: normalizedAlbumArtist,
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
@@ -1620,7 +1689,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Actual quality: $actualQuality');
|
_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
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
_log.d(
|
_log.d(
|
||||||
@@ -1690,7 +1758,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
name: trackToDownload.name,
|
name: trackToDownload.name,
|
||||||
artistName: trackToDownload.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: backendAlbum ?? trackToDownload.albumName,
|
albumName: backendAlbum ?? trackToDownload.albumName,
|
||||||
albumArtist: trackToDownload.albumArtist,
|
albumArtist: normalizedAlbumArtist,
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
duration: trackToDownload.duration,
|
duration: trackToDownload.duration,
|
||||||
isrc: trackToDownload.isrc,
|
isrc: trackToDownload.isrc,
|
||||||
@@ -1699,6 +1767,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
||||||
deezerId: trackToDownload.deezerId,
|
deezerId: trackToDownload.deezerId,
|
||||||
availability: trackToDownload.availability,
|
availability: trackToDownload.availability,
|
||||||
|
albumType: trackToDownload.albumType,
|
||||||
|
source: trackToDownload.source,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1779,6 +1849,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Log cover URL for debugging
|
// Log cover URL for debugging
|
||||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||||
|
|
||||||
|
final historyAlbumArtist =
|
||||||
|
(normalizedAlbumArtist != null &&
|
||||||
|
normalizedAlbumArtist != trackToDownload.artistName)
|
||||||
|
? normalizedAlbumArtist
|
||||||
|
: null;
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(downloadHistoryProvider.notifier)
|
.read(downloadHistoryProvider.notifier)
|
||||||
.addToHistory(
|
.addToHistory(
|
||||||
@@ -1793,7 +1869,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
||||||
? backendAlbum
|
? backendAlbum
|
||||||
: trackToDownload.albumName,
|
: trackToDownload.albumName,
|
||||||
albumArtist: trackToDownload.albumArtist,
|
albumArtist: historyAlbumArtist,
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
service: result['service'] as String? ?? item.service,
|
service: result['service'] as String? ?? item.service,
|
||||||
@@ -1822,8 +1898,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
removeItem(item.id);
|
removeItem(item.id);
|
||||||
}
|
}
|
||||||
} else {
|
} 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 errorMsg = result['error'] as String? ?? 'Download failed';
|
||||||
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
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
|
// Convert error type string to enum
|
||||||
DownloadErrorType errorType;
|
DownloadErrorType errorType;
|
||||||
@@ -1867,6 +1957,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} 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);
|
_log.e('Exception: $e', e, stackTrace);
|
||||||
|
|
||||||
String errorMsg = e.toString();
|
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);
|
state = state.copyWith(showExtensionStore: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLocale(String locale) {
|
||||||
|
state = state.copyWith(locale: locale);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
final _log = AppLogger('StoreProvider');
|
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
|
/// Extension categories
|
||||||
class StoreCategory {
|
class StoreCategory {
|
||||||
static const String metadata = 'metadata';
|
static const String metadata = 'metadata';
|
||||||
@@ -91,6 +110,12 @@ class StoreExtension {
|
|||||||
hasUpdate: json['has_update'] as bool? ?? false,
|
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
|
/// State for extension store
|
||||||
@@ -161,6 +186,11 @@ class StoreState {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Count of extensions with updates available
|
||||||
|
int get updatesAvailableCount {
|
||||||
|
return extensions.where((e) => e.hasUpdate).length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for managing extension store
|
/// Provider for managing extension store
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ class TrackState {
|
|||||||
final String? artistId;
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
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<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
|
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText; // For back button handling
|
||||||
|
final bool isShowingRecentAccess; // For recent access mode
|
||||||
final String? searchExtensionId; // Extension ID used for current search results
|
final String? searchExtensionId; // Extension ID used for current search results
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
@@ -32,9 +36,13 @@ class TrackState {
|
|||||||
this.artistId,
|
this.artistId,
|
||||||
this.artistName,
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.monthlyListeners,
|
||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
|
this.artistTopTracks,
|
||||||
this.searchArtists,
|
this.searchArtists,
|
||||||
this.hasSearchText = false,
|
this.hasSearchText = false,
|
||||||
|
this.isShowingRecentAccess = false,
|
||||||
this.searchExtensionId,
|
this.searchExtensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,9 +58,13 @@ class TrackState {
|
|||||||
String? artistId,
|
String? artistId,
|
||||||
String? artistName,
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
|
String? headerImageUrl,
|
||||||
|
int? monthlyListeners,
|
||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
|
List<Track>? artistTopTracks,
|
||||||
List<SearchArtist>? searchArtists,
|
List<SearchArtist>? searchArtists,
|
||||||
bool? hasSearchText,
|
bool? hasSearchText,
|
||||||
|
bool? isShowingRecentAccess,
|
||||||
String? searchExtensionId,
|
String? searchExtensionId,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
@@ -65,9 +77,13 @@ class TrackState {
|
|||||||
artistId: artistId ?? this.artistId,
|
artistId: artistId ?? this.artistId,
|
||||||
artistName: artistName ?? this.artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
|
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
|
||||||
|
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
|
||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
|
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||||
searchArtists: searchArtists ?? this.searchArtists,
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
|
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||||
searchExtensionId: searchExtensionId,
|
searchExtensionId: searchExtensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,6 +98,7 @@ class ArtistAlbum {
|
|||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String albumType; // album, single, compilation
|
final String albumType; // album, single, compilation
|
||||||
final String artists;
|
final String artists;
|
||||||
|
final String? providerId; // Extension ID if from extension
|
||||||
|
|
||||||
const ArtistAlbum({
|
const ArtistAlbum({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -91,6 +108,7 @@ class ArtistAlbum {
|
|||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.albumType,
|
required this.albumType,
|
||||||
required this.artists,
|
required this.artists,
|
||||||
|
this.providerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,13 +187,21 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final artistData = result['artist'] as Map<String, dynamic>;
|
final artistData = result['artist'] as Map<String, dynamic>;
|
||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
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(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistData['id'] as String?,
|
artistId: artistData['id'] as String?,
|
||||||
artistName: artistData['name'] as String?,
|
artistName: artistData['name'] as String?,
|
||||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] 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,
|
artistAlbums: albums,
|
||||||
|
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -275,12 +301,19 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||||
(e) => e.enabled && e.hasMetadataProvider,
|
(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
|
// Use Deezer or Spotify based on settings
|
||||||
final source = metadataSource ?? 'deezer';
|
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;
|
Map<String, dynamic> results;
|
||||||
List<Track> extensionTracks = [];
|
List<Track> extensionTracks = [];
|
||||||
@@ -453,6 +486,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: track.trackNumber,
|
trackNumber: track.trackNumber,
|
||||||
discNumber: track.discNumber,
|
discNumber: track.discNumber,
|
||||||
releaseDate: track.releaseDate,
|
releaseDate: track.releaseDate,
|
||||||
|
albumType: track.albumType,
|
||||||
|
source: track.source,
|
||||||
availability: ServiceAvailability(
|
availability: ServiceAvailability(
|
||||||
tidal: availability['tidal'] as bool? ?? false,
|
tidal: availability['tidal'] as bool? ?? false,
|
||||||
qobuz: availability['qobuz'] as bool? ?? false,
|
qobuz: availability['qobuz'] as bool? ?? false,
|
||||||
@@ -480,6 +515,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = state.copyWith(hasSearchText: 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) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: data['spotify_id'] as String? ?? '',
|
||||||
@@ -506,13 +563,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
durationMs = durationValue.toInt();
|
durationMs = durationValue.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get item_type - can be 'track', 'album', or 'playlist'
|
||||||
|
final itemType = data['item_type']?.toString();
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||||
albumArtist: data['album_artist']?.toString(),
|
albumArtist: data['album_artist']?.toString(),
|
||||||
coverUrl: data['images']?.toString(),
|
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||||
isrc: data['isrc']?.toString(),
|
isrc: data['isrc']?.toString(),
|
||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
@@ -520,6 +580,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||||
albumType: data['album_type']?.toString(),
|
albumType: data['album_type']?.toString(),
|
||||||
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,9 +590,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
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',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
artists: data['artists'] as String? ?? '',
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/track.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
|
||||||
@@ -62,6 +64,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
// Priority: widget.tracks > cache > fetch
|
||||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
if (_tracks == null) {
|
if (_tracks == null) {
|
||||||
@@ -260,7 +275,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||||
const SizedBox(width: 4),
|
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(
|
FilledButton.icon(
|
||||||
onPressed: () => _downloadAll(context),
|
onPressed: () => _downloadAll(context),
|
||||||
icon: const Icon(Icons.download),
|
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))),
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -289,7 +304,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
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,
|
coverUrl: track.coverUrl,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
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,
|
artistName: widget.albumName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Rate Limited',
|
context.l10n.errorRateLimited,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -383,7 +398,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Too many requests. Please wait a moment and try again.',
|
context.l10n.errorRateLimitedMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -476,7 +491,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
final fileExists = await File(historyItem.filePath).exists();
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
if (context.mounted) {
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,50 +1,87 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.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 {
|
class _ArtistCache {
|
||||||
static final Map<String, _CacheEntry> _cache = {};
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
static const Duration _ttl = Duration(minutes: 10);
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
static List<ArtistAlbum>? get(String artistId) {
|
static _CacheEntry? get(String artistId) {
|
||||||
final entry = _cache[artistId];
|
final entry = _cache[artistId];
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
if (DateTime.now().isAfter(entry.expiresAt)) {
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
_cache.remove(artistId);
|
_cache.remove(artistId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return entry.albums;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void set(String artistId, List<ArtistAlbum> albums) {
|
static void set(String artistId, {
|
||||||
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
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 {
|
class _CacheEntry {
|
||||||
final List<ArtistAlbum> albums;
|
final List<ArtistAlbum> albums;
|
||||||
|
final List<Track>? topTracks;
|
||||||
|
final String? headerImageUrl;
|
||||||
|
final int? monthlyListeners;
|
||||||
final DateTime expiresAt;
|
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 {
|
class ArtistScreen extends ConsumerStatefulWidget {
|
||||||
final String artistId;
|
final String artistId;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
final String? coverUrl;
|
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({
|
const ArtistScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artistId,
|
required this.artistId,
|
||||||
required this.artistName,
|
required this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.monthlyListeners,
|
||||||
this.albums,
|
this.albums,
|
||||||
|
this.topTracks,
|
||||||
|
this.extensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||||
bool _isLoadingDiscography = false;
|
bool _isLoadingDiscography = false;
|
||||||
List<ArtistAlbum>? _albums;
|
List<ArtistAlbum>? _albums;
|
||||||
|
List<Track>? _topTracks;
|
||||||
|
String? _headerImageUrl;
|
||||||
|
int? _monthlyListeners;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Priority: widget.albums > cache > fetch
|
|
||||||
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
// Record access for recent history
|
||||||
if (_albums == null) {
|
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();
|
_fetchDiscography();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,31 +155,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
setState(() => _isLoadingDiscography = true);
|
setState(() => _isLoadingDiscography = true);
|
||||||
try {
|
try {
|
||||||
List<ArtistAlbum> albums;
|
List<ArtistAlbum> albums;
|
||||||
|
List<Track>? topTracks;
|
||||||
|
String? headerImage;
|
||||||
|
int? listeners;
|
||||||
|
|
||||||
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||||
if (widget.artistId.startsWith('deezer:')) {
|
if (widget.artistId.startsWith('deezer:')) {
|
||||||
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
|
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
} else {
|
} else {
|
||||||
// Spotify artist - use fallback method
|
// Spotify artist - use extension handler via URL
|
||||||
// ignore: avoid_print
|
|
||||||
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
|
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
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
|
// Store in cache (preserve existing values if new ones are null)
|
||||||
_ArtistCache.set(widget.artistId, albums);
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_albums = albums;
|
_albums = albums;
|
||||||
|
_topTracks = topTracks;
|
||||||
|
_headerImageUrl = finalHeaderImage;
|
||||||
|
_monthlyListeners = finalListeners;
|
||||||
_isLoadingDiscography = false;
|
_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) {
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
return ArtistAlbum(
|
return ArtistAlbum(
|
||||||
id: data['id'] as String? ?? '',
|
id: data['id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
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',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
artists: data['artists'] as String? ?? '',
|
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();
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: CustomScrollView(
|
||||||
children: [
|
slivers: [
|
||||||
CustomScrollView(
|
_buildHeader(context, colorScheme),
|
||||||
slivers: [
|
if (_isLoadingDiscography)
|
||||||
_buildAppBar(context, colorScheme),
|
const SliverToBoxAdapter(child: Padding(
|
||||||
_buildInfoCard(context, colorScheme),
|
padding: EdgeInsets.all(32),
|
||||||
if (_isLoadingDiscography)
|
child: Center(child: CircularProgressIndicator()),
|
||||||
const SliverToBoxAdapter(child: Padding(
|
)),
|
||||||
padding: EdgeInsets.all(32),
|
if (_error != null)
|
||||||
child: Center(child: CircularProgressIndicator()),
|
SliverToBoxAdapter(child: Padding(
|
||||||
)),
|
padding: const EdgeInsets.all(16),
|
||||||
if (_error != null)
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
SliverToBoxAdapter(child: Padding(
|
)),
|
||||||
padding: const EdgeInsets.all(16),
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
child: _buildErrorWidget(_error!, colorScheme),
|
// Popular tracks section
|
||||||
)),
|
if (_topTracks != null && _topTracks!.isNotEmpty)
|
||||||
if (!_isLoadingDiscography && _error == null) ...[
|
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
|
||||||
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
// Discography sections
|
||||||
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
|
if (albumsOnly.isNotEmpty)
|
||||||
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
|
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
|
||||||
],
|
if (singles.isNotEmpty)
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
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) {
|
/// Build Spotify-style header with full-width image and artist name overlay
|
||||||
// Validate image URL - must be non-null, non-empty, and have a valid host
|
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
final hasValidImage = widget.coverUrl != null &&
|
// Use header image if available, otherwise fall back to cover URL
|
||||||
widget.coverUrl!.isNotEmpty &&
|
// Prefer: fetched header > widget header > widget cover
|
||||||
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
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(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 380,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
@@ -174,49 +334,84 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
// Background image - full width, no circular crop
|
||||||
if (hasValidImage)
|
if (hasValidImage)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
alignment: Alignment.topCenter, // Show top of image (faces)
|
||||||
colorBlendMode: BlendMode.darken,
|
memCacheWidth: 800,
|
||||||
memCacheWidth: 600,
|
placeholder: (context, url) => Container(
|
||||||
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
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(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
colors: [
|
||||||
stops: const [0.0, 0.7, 1.0],
|
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(
|
// Artist name and listeners at bottom
|
||||||
child: Padding(
|
Positioned(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
left: 16,
|
||||||
child: Container(
|
right: 16,
|
||||||
width: 140,
|
bottom: 16,
|
||||||
height: 140,
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
shape: BoxShape.circle,
|
mainAxisSize: MainAxisSize.min,
|
||||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
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(
|
if (listenersText != null) ...[
|
||||||
child: hasValidImage
|
const SizedBox(height: 4),
|
||||||
? CachedNetworkImage(
|
Text(
|
||||||
imageUrl: widget.coverUrl!,
|
listenersText,
|
||||||
fit: BoxFit.cover,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
memCacheWidth: 280,
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
errorWidget: (context, url, error) => Container(
|
shadows: [
|
||||||
color: colorScheme.surfaceContainerHighest,
|
Shadow(
|
||||||
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
offset: const Offset(0, 1),
|
||||||
),
|
blurRadius: 2,
|
||||||
)
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -224,44 +419,280 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
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),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
/// Build Popular tracks section like Spotify
|
||||||
return SliverToBoxAdapter(
|
Widget _buildPopularSection(ColorScheme colorScheme) {
|
||||||
child: Padding(
|
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Card(
|
// Show max 5 tracks
|
||||||
elevation: 0,
|
final tracks = _topTracks!.take(5).toList();
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
return Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(20),
|
children: [
|
||||||
child: Column(
|
Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||||
children: [
|
child: Text(
|
||||||
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
context.l10n.artistPopular,
|
||||||
const SizedBox(height: 8),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
if (_albums != null)
|
fontWeight: FontWeight.bold,
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
...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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||||
child: Row(
|
child: Text(
|
||||||
children: [
|
'$title (${albums.length})',
|
||||||
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
const SizedBox(width: 8),
|
fontWeight: FontWeight.bold,
|
||||||
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 210,
|
height: 220,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = albums[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),
|
onTap: () => _navigateToAlbum(album),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 140,
|
width: 140,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Card(
|
child: Column(
|
||||||
elevation: 0,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: colorScheme.surfaceContainerLow,
|
children: [
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
// Album cover
|
||||||
child: Padding(
|
ClipRRect(
|
||||||
padding: const EdgeInsets.all(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Column(
|
child: album.coverUrl != null
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? CachedNetworkImage(
|
||||||
children: [
|
imageUrl: album.coverUrl!,
|
||||||
ClipRRect(
|
width: 140,
|
||||||
borderRadius: BorderRadius.circular(12),
|
height: 140,
|
||||||
child: album.coverUrl != null
|
fit: BoxFit.cover,
|
||||||
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
memCacheWidth: 280,
|
||||||
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
placeholder: (context, url) => Container(
|
||||||
),
|
width: 140,
|
||||||
const SizedBox(height: 6),
|
height: 140,
|
||||||
Expanded(
|
color: colorScheme.surfaceContainerHighest,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
],
|
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) {
|
void _navigateToAlbum(ArtistAlbum album) {
|
||||||
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
Navigator.push(context, MaterialPageRoute(
|
|
||||||
builder: (context) => AlbumScreen(
|
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||||
albumId: album.id,
|
Navigator.push(context, MaterialPageRoute(
|
||||||
albumName: album.name,
|
builder: (context) => ExtensionAlbumScreen(
|
||||||
coverUrl: album.coverUrl,
|
extensionId: album.providerId!,
|
||||||
// tracks: null - will be fetched in AlbumScreen
|
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) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
final isRateLimit = error.contains('429') ||
|
final isRateLimit = error.contains('429') ||
|
||||||
error.toLowerCase().contains('rate limit') ||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
@@ -366,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer,
|
color: colorScheme.errorContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -378,7 +839,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Rate Limited',
|
context.l10n.errorRateLimited,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -386,7 +847,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Too many requests. Please wait a moment and try again.',
|
context.l10n.errorRateLimitedMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -401,11 +862,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error display
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:open_filex/open_filex.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/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
|
||||||
@@ -83,19 +85,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Delete Selected'),
|
title: Text(context.l10n.downloadedAlbumDeleteSelected),
|
||||||
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
|
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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 {
|
Future<void> _openFile(String filePath) async {
|
||||||
try {
|
try {
|
||||||
await OpenFilex.open(filePath);
|
final mimeType = audioMimeTypeForPath(filePath);
|
||||||
|
await OpenFilex.open(filePath, type: mimeType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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: [
|
children: [
|
||||||
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
const SizedBox(width: 4),
|
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: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
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(),
|
const Spacer(),
|
||||||
if (!_isSelectionMode)
|
if (!_isSelectionMode)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||||
icon: const Icon(Icons.checklist, size: 18),
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
label: const Text('Select'),
|
label: Text(context.l10n.actionSelect),
|
||||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -521,11 +524,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$selectedCount selected',
|
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
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),
|
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),
|
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),
|
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -553,8 +556,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
label: Text(
|
label: Text(
|
||||||
selectedCount > 0
|
selectedCount > 0
|
||||||
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
|
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
|
||||||
: 'Select tracks to delete',
|
: context.l10n.downloadedAlbumSelectToDelete,
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
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/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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 {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
|
|
||||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||||
final track = ref.watch(trackProvider).tracks[index];
|
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(
|
return ListTile(
|
||||||
leading: track.coverUrl != null
|
leading: track.coverUrl != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
@@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
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),
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistName,
|
subtitleText,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
trailing: isCollection
|
||||||
_formatDuration(track.duration),
|
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
: Text(
|
||||||
color: colorScheme.onSurfaceVariant,
|
_formatDuration(track.duration),
|
||||||
),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
onTap: () => _downloadTrack(index),
|
),
|
||||||
|
),
|
||||||
|
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
import 'package:spotiflac_android/screens/store_tab.dart';
|
||||||
@@ -77,7 +79,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
// Show snackbar
|
// Show snackbar
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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) {
|
if (_currentIndex != index) {
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
// 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
|
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
if (isKeyboardVisible) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,9 +173,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
} else {
|
} else {
|
||||||
_lastBackPress = now;
|
_lastBackPress = now;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Press back again to exit'),
|
content: Text(context.l10n.pressBackAgainToExit),
|
||||||
duration: Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -176,6 +187,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
final trackState = ref.watch(trackProvider);
|
final trackState = ref.watch(trackProvider);
|
||||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
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)
|
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
@@ -187,6 +199,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
!trackState.hasSearchText &&
|
!trackState.hasSearchText &&
|
||||||
!trackState.hasContent &&
|
!trackState.hasContent &&
|
||||||
!trackState.isLoading &&
|
!trackState.isLoading &&
|
||||||
|
!trackState.isShowingRecentAccess &&
|
||||||
!isKeyboardVisible;
|
!isKeyboardVisible;
|
||||||
|
|
||||||
// Build tabs and destinations based on settings
|
// Build tabs and destinations based on settings
|
||||||
@@ -201,11 +214,12 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
const SettingsTab(),
|
const SettingsTab(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final l10n = context.l10n;
|
||||||
final destinations = <NavigationDestination>[
|
final destinations = <NavigationDestination>[
|
||||||
const NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
selectedIcon: Icon(Icons.home),
|
selectedIcon: const Icon(Icons.home),
|
||||||
label: 'Home',
|
label: l10n.navHome,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
@@ -218,18 +232,26 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
label: Text('$queueState'),
|
label: Text('$queueState'),
|
||||||
child: const Icon(Icons.history),
|
child: const Icon(Icons.history),
|
||||||
),
|
),
|
||||||
label: 'History',
|
label: l10n.navHistory,
|
||||||
),
|
),
|
||||||
if (showStore)
|
if (showStore)
|
||||||
const NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.store_outlined),
|
icon: Badge(
|
||||||
selectedIcon: Icon(Icons.store),
|
isLabelVisible: storeUpdatesCount > 0,
|
||||||
label: 'Store',
|
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(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
selectedIcon: Icon(Icons.settings),
|
selectedIcon: const Icon(Icons.settings),
|
||||||
label: 'Settings',
|
label: l10n.navSettings,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/track.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
@@ -114,7 +115,7 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||||
const SizedBox(width: 4),
|
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(
|
FilledButton.icon(
|
||||||
onPressed: () => _downloadAll(context, ref),
|
onPressed: () => _downloadAll(context, ref),
|
||||||
icon: const Icon(Icons.download),
|
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))),
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -141,7 +142,7 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
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,
|
coverUrl: track.coverUrl,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
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,
|
artistName: playlistName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
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();
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
if (context.mounted) {
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
|
||||||
@@ -14,19 +15,19 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Download Queue'),
|
title: Text(context.l10n.queueTitle),
|
||||||
actions: [
|
actions: [
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueState.items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_sweep),
|
icon: const Icon(Icons.delete_sweep),
|
||||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||||
tooltip: 'Clear completed',
|
tooltip: context.l10n.queueClearCompleted,
|
||||||
),
|
),
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueState.items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear_all),
|
icon: const Icon(Icons.clear_all),
|
||||||
onPressed: () => _showClearAllDialog(context, ref),
|
onPressed: () => _showClearAllDialog(context, ref),
|
||||||
tooltip: 'Clear all',
|
tooltip: context.l10n.queueClearAll,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -51,14 +52,14 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No downloads in queue',
|
context.l10n.queueEmpty,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Add tracks from the home screen',
|
context.l10n.queueEmptySubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
@@ -177,7 +178,7 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.error, color: colorScheme.error),
|
Icon(Icons.error, color: colorScheme.error),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('Download Failed'),
|
Text(context.l10n.queueDownloadFailed),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
@@ -185,10 +186,10 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
Text('Artist: ${item.track.artistName}'),
|
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
|
||||||
const SizedBox(height: 16),
|
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),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -197,7 +198,7 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
item.error ?? 'Unknown error',
|
item.error ?? context.l10n.queueUnknownError,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -211,7 +212,7 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Close'),
|
child: Text(context.l10n.dialogClose),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -223,19 +224,19 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Clear All'),
|
title: Text(context.l10n.queueClearAll),
|
||||||
content: const Text('Are you sure you want to clear all downloads?'),
|
content: Text(context.l10n.queueClearAllMessage),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||||
Navigator.pop(context);
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:open_filex/open_filex.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/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
@@ -138,21 +140,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Delete Selected'),
|
title: Text(context.l10n.dialogDeleteSelectedTitle),
|
||||||
content: Text(
|
content: Text(context.l10n.dialogDeleteSelectedMessage(count)),
|
||||||
'Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
|
||||||
'Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,35 +226,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Future<void> _openFile(String filePath) async {
|
Future<void> _openFile(String filePath) async {
|
||||||
final cleanPath = _cleanFilePath(filePath);
|
final cleanPath = _cleanFilePath(filePath);
|
||||||
try {
|
try {
|
||||||
// Determine MIME type based on file extension
|
final mimeType = audioMimeTypeForPath(cleanPath);
|
||||||
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/*';
|
|
||||||
}
|
|
||||||
await OpenFilex.open(cleanPath, type: mimeType);
|
await OpenFilex.open(cleanPath, type: mimeType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
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,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'History',
|
context.l10n.historyTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (14 * expandRatio),
|
fontSize: 20 + (14 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -611,7 +587,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'All',
|
label: context.l10n.historyFilterAll,
|
||||||
count: allHistoryItems.length,
|
count: allHistoryItems.length,
|
||||||
isSelected: historyFilterMode == 'all',
|
isSelected: historyFilterMode == 'all',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -620,7 +596,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'Albums',
|
label: context.l10n.historyFilterAlbums,
|
||||||
count: albumCount,
|
count: albumCount,
|
||||||
isSelected: historyFilterMode == 'albums',
|
isSelected: historyFilterMode == 'albums',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -629,7 +605,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'Singles',
|
label: context.l10n.historyFilterSingles,
|
||||||
count: singleCount,
|
count: singleCount,
|
||||||
isSelected: historyFilterMode == 'singles',
|
isSelected: historyFilterMode == 'singles',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -805,7 +781,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
? () => _enterSelectionMode(historyItems.first.id)
|
? () => _enterSelectionMode(historyItems.first.id)
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.checklist, size: 18),
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
label: const Text('Select'),
|
label: Text(context.l10n.actionSelect),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class AboutPage extends StatelessWidget {
|
class AboutPage extends StatelessWidget {
|
||||||
@@ -41,7 +42,7 @@ class AboutPage extends StatelessWidget {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'About',
|
context.l10n.aboutTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -62,27 +63,27 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Contributors section
|
// Contributors section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Contributors'),
|
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: AppInfo.mobileAuthor,
|
name: AppInfo.mobileAuthor,
|
||||||
description: 'Mobile version developer',
|
description: context.l10n.aboutMobileDeveloper,
|
||||||
githubUsername: AppInfo.mobileAuthor,
|
githubUsername: AppInfo.mobileAuthor,
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: AppInfo.originalAuthor,
|
name: AppInfo.originalAuthor,
|
||||||
description: 'Creator of the original SpotiFLAC',
|
description: context.l10n.aboutOriginalCreator,
|
||||||
githubUsername: AppInfo.originalAuthor,
|
githubUsername: AppInfo.originalAuthor,
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: 'Amonoman',
|
name: 'Amonoman',
|
||||||
description: 'The talented artist who created our beautiful app logo!',
|
description: context.l10n.aboutLogoArtist,
|
||||||
githubUsername: 'Amonoman',
|
githubUsername: 'Amonoman',
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -91,35 +92,35 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Special Thanks section
|
// Special Thanks section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Special Thanks'),
|
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: 'uimaxbai',
|
name: 'binimum',
|
||||||
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
|
description: context.l10n.aboutBinimumDesc,
|
||||||
githubUsername: 'uimaxbai',
|
githubUsername: 'binimum',
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: 'sachinsenal0x64',
|
name: 'sachinsenal0x64',
|
||||||
description: 'The original HiFi project creator. The foundation of Tidal integration!',
|
description: context.l10n.aboutSachinsenalDesc,
|
||||||
githubUsername: 'sachinsenal0x64',
|
githubUsername: 'sachinsenal0x64',
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_AboutSettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
title: 'DoubleDouble',
|
title: context.l10n.aboutDoubleDouble,
|
||||||
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
subtitle: context.l10n.aboutDoubleDoubleDesc,
|
||||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_AboutSettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.music_note_outlined,
|
icon: Icons.music_note_outlined,
|
||||||
title: 'DAB Music',
|
title: context.l10n.aboutDabMusic,
|
||||||
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
subtitle: context.l10n.aboutDabMusicDesc,
|
||||||
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -128,37 +129,37 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Links section
|
// Links section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Links'),
|
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.phone_android,
|
icon: Icons.phone_android,
|
||||||
title: 'Mobile source code',
|
title: context.l10n.aboutMobileSource,
|
||||||
subtitle: 'github.com/${AppInfo.githubRepo}',
|
subtitle: 'github.com/${AppInfo.githubRepo}',
|
||||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.computer,
|
icon: Icons.computer,
|
||||||
title: 'PC source code',
|
title: context.l10n.aboutPCSource,
|
||||||
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
||||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.bug_report_outlined,
|
icon: Icons.bug_report_outlined,
|
||||||
title: 'Report an issue',
|
title: context.l10n.aboutReportIssue,
|
||||||
subtitle: 'Report any problems you encounter',
|
subtitle: context.l10n.aboutReportIssueSubtitle,
|
||||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.lightbulb_outline,
|
icon: Icons.lightbulb_outline,
|
||||||
title: 'Feature request',
|
title: context.l10n.aboutFeatureRequest,
|
||||||
subtitle: 'Suggest new features for the app',
|
subtitle: context.l10n.aboutFeatureRequestSubtitle,
|
||||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -167,16 +168,16 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Support section
|
// Support section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Support'),
|
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.coffee_outlined,
|
icon: Icons.coffee_outlined,
|
||||||
title: 'Buy me a coffee',
|
title: context.l10n.aboutBuyMeCoffee,
|
||||||
subtitle: 'Support development on Ko-fi',
|
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
|
||||||
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -185,15 +186,15 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// App info section
|
// App info section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'App'),
|
child: SettingsSectionHeader(title: context.l10n.aboutApp),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
title: 'Version',
|
title: context.l10n.aboutVersion,
|
||||||
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -300,7 +301,7 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Description
|
// Description
|
||||||
Text(
|
Text(
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
|
context.l10n.aboutAppDescription,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
@@ -32,7 +34,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
flexibleSpace: _AppBarTitle(
|
flexibleSpace: _AppBarTitle(
|
||||||
title: 'Appearance',
|
title: context.l10n.appearanceTitle,
|
||||||
topPadding: topPadding,
|
topPadding: topPadding,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -49,8 +51,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Color section
|
// Color section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Color'),
|
child: SettingsSectionHeader(title: context.l10n.sectionColor),
|
||||||
),
|
),
|
||||||
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -58,8 +60,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.wallpaper,
|
icon: Icons.wallpaper,
|
||||||
title: 'Dynamic Color',
|
title: context.l10n.appearanceDynamicColor,
|
||||||
subtitle: 'Use colors from your wallpaper',
|
subtitle: context.l10n.appearanceDynamicColorSubtitle,
|
||||||
value: themeSettings.useDynamicColor,
|
value: themeSettings.useDynamicColor,
|
||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(themeProvider.notifier)
|
.read(themeProvider.notifier)
|
||||||
@@ -82,8 +84,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Theme section
|
// Theme section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Theme'),
|
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -96,8 +98,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
if (Theme.of(context).brightness == Brightness.dark)
|
if (Theme.of(context).brightness == Brightness.dark)
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.brightness_2,
|
icon: Icons.brightness_2,
|
||||||
title: 'AMOLED Dark',
|
title: context.l10n.appearanceAmoledDark,
|
||||||
subtitle: 'Pure black background',
|
subtitle: context.l10n.appearanceAmoledDarkSubtitle,
|
||||||
value: themeSettings.useAmoled,
|
value: themeSettings.useAmoled,
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
ref.read(themeProvider.notifier).setUseAmoled(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
|
// Layout section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Layout'),
|
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -283,7 +302,7 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
isDark ? 'Dark Mode' : 'Light Mode',
|
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -451,21 +470,21 @@ class _ThemeModeSelector extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ThemeModeChip(
|
_ThemeModeChip(
|
||||||
icon: Icons.brightness_auto,
|
icon: Icons.brightness_auto,
|
||||||
label: 'System',
|
label: context.l10n.appearanceThemeSystem,
|
||||||
isSelected: currentMode == ThemeMode.system,
|
isSelected: currentMode == ThemeMode.system,
|
||||||
onTap: () => onChanged(ThemeMode.system),
|
onTap: () => onChanged(ThemeMode.system),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ThemeModeChip(
|
_ThemeModeChip(
|
||||||
icon: Icons.light_mode,
|
icon: Icons.light_mode,
|
||||||
label: 'Light',
|
label: context.l10n.appearanceThemeLight,
|
||||||
isSelected: currentMode == ThemeMode.light,
|
isSelected: currentMode == ThemeMode.light,
|
||||||
onTap: () => onChanged(ThemeMode.light),
|
onTap: () => onChanged(ThemeMode.light),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ThemeModeChip(
|
_ThemeModeChip(
|
||||||
icon: Icons.dark_mode,
|
icon: Icons.dark_mode,
|
||||||
label: 'Dark',
|
label: context.l10n.appearanceThemeDark,
|
||||||
isSelected: currentMode == ThemeMode.dark,
|
isSelected: currentMode == ThemeMode.dark,
|
||||||
onTap: () => onChanged(ThemeMode.dark),
|
onTap: () => onChanged(ThemeMode.dark),
|
||||||
),
|
),
|
||||||
@@ -575,7 +594,7 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'History View',
|
context.l10n.appearanceHistoryView,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -585,14 +604,14 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ViewModeChip(
|
_ViewModeChip(
|
||||||
icon: Icons.view_list,
|
icon: Icons.view_list,
|
||||||
label: 'List',
|
label: context.l10n.appearanceHistoryViewList,
|
||||||
isSelected: currentMode == 'list',
|
isSelected: currentMode == 'list',
|
||||||
onTap: () => onChanged('list'),
|
onTap: () => onChanged('list'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ViewModeChip(
|
_ViewModeChip(
|
||||||
icon: Icons.grid_view,
|
icon: Icons.grid_view,
|
||||||
label: 'Grid',
|
label: context.l10n.appearanceHistoryViewGrid,
|
||||||
isSelected: currentMode == 'grid',
|
isSelected: currentMode == 'grid',
|
||||||
onTap: () => onChanged('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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path_provider/path_provider.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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
@@ -55,7 +56,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
bottom: 16,
|
bottom: 16,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Download',
|
context.l10n.downloadTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -68,8 +69,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Service section
|
// Service section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Service'),
|
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -85,17 +86,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Quality section
|
// Quality section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Audio Quality'),
|
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
title: 'Ask Before Download',
|
title: context.l10n.downloadAskBeforeDownload,
|
||||||
subtitle: isBuiltInService
|
subtitle: isBuiltInService
|
||||||
? 'Choose quality for each download'
|
? context.l10n.downloadAskQualitySubtitle
|
||||||
: 'Select a built-in service to enable',
|
: 'Select a built-in service to enable',
|
||||||
value: settings.askQualityBeforeDownload,
|
value: settings.askQualityBeforeDownload,
|
||||||
// Not selected visually if extension is active
|
// Not selected visually if extension is active
|
||||||
@@ -106,24 +107,24 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'FLAC Lossless',
|
title: context.l10n.qualityFlacLossless,
|
||||||
subtitle: '16-bit / 44.1kHz',
|
subtitle: context.l10n.qualityFlacLosslessSubtitle,
|
||||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('LOSSLESS'),
|
.setAudioQuality('LOSSLESS'),
|
||||||
),
|
),
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'Hi-Res FLAC',
|
title: context.l10n.qualityHiResFlac,
|
||||||
subtitle: '24-bit / up to 96kHz',
|
subtitle: context.l10n.qualityHiResFlacSubtitle,
|
||||||
isSelected: settings.audioQuality == 'HI_RES',
|
isSelected: settings.audioQuality == 'HI_RES',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('HI_RES'),
|
.setAudioQuality('HI_RES'),
|
||||||
),
|
),
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'Hi-Res FLAC Max',
|
title: context.l10n.qualityHiResFlacMax,
|
||||||
subtitle: '24-bit / up to 192kHz',
|
subtitle: context.l10n.qualityHiResFlacMaxSubtitle,
|
||||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -159,15 +160,15 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// File settings section
|
// File settings section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'File Settings'),
|
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.text_fields,
|
icon: Icons.text_fields,
|
||||||
title: 'Filename Format',
|
title: context.l10n.downloadFilenameFormat,
|
||||||
subtitle: settings.filenameFormat,
|
subtitle: settings.filenameFormat,
|
||||||
onTap: () => _showFormatEditor(
|
onTap: () => _showFormatEditor(
|
||||||
context,
|
context,
|
||||||
@@ -177,17 +178,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: 'Download Directory',
|
title: context.l10n.downloadDirectory,
|
||||||
subtitle: settings.downloadDirectory.isEmpty
|
subtitle: settings.downloadDirectory.isEmpty
|
||||||
? (Platform.isIOS
|
? (Platform.isIOS
|
||||||
? 'App Documents Folder'
|
? context.l10n.setupAppDocumentsFolder
|
||||||
: 'Music/SpotiFLAC')
|
: 'Music/SpotiFLAC')
|
||||||
: settings.downloadDirectory,
|
: settings.downloadDirectory,
|
||||||
onTap: () => _pickDirectory(context, ref),
|
onTap: () => _pickDirectory(context, ref),
|
||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.library_music_outlined,
|
icon: Icons.library_music_outlined,
|
||||||
title: 'Separate Singles Folder',
|
title: context.l10n.downloadSeparateSinglesFolder,
|
||||||
subtitle: settings.separateSingles
|
subtitle: settings.separateSingles
|
||||||
? 'Albums/ and Singles/ folders'
|
? 'Albums/ and Singles/ folders'
|
||||||
: 'All files in same structure',
|
: 'All files in same structure',
|
||||||
@@ -199,10 +200,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
if (settings.separateSingles)
|
if (settings.separateSingles)
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: 'Album Folder Structure',
|
title: context.l10n.downloadAlbumFolderStructure,
|
||||||
subtitle: settings.albumFolderStructure == 'album_only'
|
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
|
||||||
? 'Albums/Album Name/'
|
|
||||||
: 'Albums/Artist/Album Name/',
|
|
||||||
onTap: () => _showAlbumFolderStructurePicker(
|
onTap: () => _showAlbumFolderStructurePicker(
|
||||||
context,
|
context,
|
||||||
ref,
|
ref,
|
||||||
@@ -212,7 +211,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
if (!settings.separateSingles)
|
if (!settings.separateSingles)
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.create_new_folder_outlined,
|
icon: Icons.create_new_folder_outlined,
|
||||||
title: 'Folder Organization',
|
title: context.l10n.downloadFolderOrganization,
|
||||||
subtitle: _getFolderOrganizationLabel(
|
subtitle: _getFolderOrganizationLabel(
|
||||||
settings.folderOrganization,
|
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) {
|
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -243,24 +255,44 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.folder_outlined),
|
leading: const Icon(Icons.folder_outlined),
|
||||||
title: const Text('Artist / Album'),
|
title: Text(context.l10n.albumFolderArtistAlbum),
|
||||||
subtitle: const Text('Albums/Artist Name/Album Name/'),
|
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
|
||||||
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
||||||
Navigator.pop(context);
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.album_outlined),
|
leading: const Icon(Icons.album_outlined),
|
||||||
title: const Text('Album Only'),
|
title: Text(context.l10n.albumFolderAlbumOnly),
|
||||||
subtitle: const Text('Albums/Album Name/'),
|
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
|
||||||
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
||||||
Navigator.pop(context);
|
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(
|
Text(
|
||||||
'Filename Format',
|
context.l10n.filenameFormat,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -402,7 +434,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
@@ -410,7 +442,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -429,7 +461,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Save Format'),
|
child: Text(context.l10n.dialogSave),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -473,7 +505,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Download Location',
|
context.l10n.setupDownloadLocationTitle,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
@@ -482,7 +514,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
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(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -490,8 +522,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
title: const Text('App Documents Folder'),
|
title: Text(context.l10n.setupAppDocumentsFolder),
|
||||||
subtitle: const Text('Recommended - accessible via Files app'),
|
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
|
||||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
@@ -503,8 +535,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
title: const Text('Choose from Files'),
|
title: Text(context.l10n.setupChooseFromFiles),
|
||||||
subtitle: const Text('Select iCloud or other location'),
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// 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),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -558,7 +590,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
case 'album':
|
case 'album':
|
||||||
return 'By Album';
|
return 'By Album';
|
||||||
case 'artist_album':
|
case 'artist_album':
|
||||||
return 'By Artist & Album';
|
return 'Artist/Album';
|
||||||
default:
|
default:
|
||||||
return 'None';
|
return 'None';
|
||||||
}
|
}
|
||||||
@@ -598,15 +630,15 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Organize downloaded files into folders',
|
context.l10n.folderOrganizationDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'None',
|
title: context.l10n.folderOrganizationNone,
|
||||||
subtitle: 'All files in download folder',
|
subtitle: context.l10n.folderOrganizationNoneSubtitle,
|
||||||
example: 'SpotiFLAC/Track.flac',
|
example: 'SpotiFLAC/Track.flac',
|
||||||
isSelected: current == 'none',
|
isSelected: current == 'none',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -615,8 +647,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist',
|
title: context.l10n.folderOrganizationByArtist,
|
||||||
subtitle: 'Separate folder for each artist',
|
subtitle: context.l10n.folderOrganizationByArtistSubtitle,
|
||||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
isSelected: current == 'artist',
|
isSelected: current == 'artist',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -625,8 +657,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Album',
|
title: context.l10n.folderOrganizationByAlbum,
|
||||||
subtitle: 'Separate folder for each album',
|
subtitle: context.l10n.folderOrganizationByAlbumSubtitle,
|
||||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
isSelected: current == 'album',
|
isSelected: current == 'album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -635,8 +667,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist & Album',
|
title: context.l10n.folderOrganizationByArtistAlbum,
|
||||||
subtitle: 'Nested folders for artist and album',
|
subtitle: context.l10n.folderOrganizationByArtistAlbumSubtitle,
|
||||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
isSelected: current == 'artist_album',
|
isSelected: current == 'artist_album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
@@ -186,12 +187,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_InfoRow(label: 'Author', value: extension.author),
|
_InfoRow(label: context.l10n.extensionAuthor, value: extension.author),
|
||||||
_InfoRow(label: 'ID', value: extension.id),
|
_InfoRow(label: context.l10n.extensionId, value: extension.id),
|
||||||
_InfoRow(label: 'Version', value: 'v${extension.version}'),
|
_InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''),
|
||||||
if (hasError && extension.errorMessage != null)
|
if (hasError && extension.errorMessage != null)
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
label: 'Error',
|
label: context.l10n.extensionError,
|
||||||
value: extension.errorMessage!,
|
value: extension.errorMessage!,
|
||||||
isError: true,
|
isError: true,
|
||||||
),
|
),
|
||||||
@@ -202,50 +203,50 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Capabilities
|
// Capabilities
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Capabilities'),
|
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.search,
|
icon: Icons.search,
|
||||||
title: 'Metadata Provider',
|
title: context.l10n.extensionMetadataProvider,
|
||||||
enabled: extension.hasMetadataProvider,
|
enabled: extension.hasMetadataProvider,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.download,
|
icon: Icons.download,
|
||||||
title: 'Download Provider',
|
title: context.l10n.extensionDownloadProvider,
|
||||||
enabled: extension.hasDownloadProvider,
|
enabled: extension.hasDownloadProvider,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.manage_search,
|
icon: Icons.manage_search,
|
||||||
title: 'Custom Search',
|
title: context.l10n.extensionsSearchProvider,
|
||||||
enabled: extension.hasCustomSearch,
|
enabled: extension.hasCustomSearch,
|
||||||
subtitle: extension.searchBehavior?.placeholder,
|
subtitle: extension.searchBehavior?.placeholder,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.compare_arrows,
|
icon: Icons.compare_arrows,
|
||||||
title: 'Custom Track Matching',
|
title: context.l10n.extensionCustomTrackMatching,
|
||||||
enabled: extension.hasCustomMatching,
|
enabled: extension.hasCustomMatching,
|
||||||
subtitle: extension.trackMatching?.strategy != null
|
subtitle: extension.trackMatching?.strategy != null
|
||||||
? 'Strategy: ${extension.trackMatching!.strategy}'
|
? context.l10n.extensionStrategy(extension.trackMatching!.strategy!)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.auto_fix_high,
|
icon: Icons.auto_fix_high,
|
||||||
title: 'Post-Processing',
|
title: context.l10n.extensionPostProcessing,
|
||||||
enabled: extension.hasPostProcessing,
|
enabled: extension.hasPostProcessing,
|
||||||
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
|
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
|
||||||
? '${extension.postProcessing!.hooks.length} hook(s) available'
|
? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.link,
|
icon: Icons.link,
|
||||||
title: 'URL Handler',
|
title: context.l10n.extensionUrlHandler,
|
||||||
enabled: extension.hasURLHandler,
|
enabled: extension.hasURLHandler,
|
||||||
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
|
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
|
||||||
? '${extension.urlHandler!.patterns.length} pattern(s)'
|
? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length)
|
||||||
: null,
|
: null,
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -257,8 +258,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
|
|
||||||
// URL Handler Section (if extension handles URLs)
|
// URL Handler Section (if extension handles URLs)
|
||||||
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
|
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'URL Handler'),
|
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -273,8 +274,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
|
|
||||||
// Quality Options Section (for download providers)
|
// Quality Options Section (for download providers)
|
||||||
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
|
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Quality Options'),
|
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -292,8 +293,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
|
|
||||||
// Post-Processing Hooks (if available)
|
// Post-Processing Hooks (if available)
|
||||||
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
|
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Post-Processing Hooks'),
|
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -311,8 +312,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
if (extension.permissions.isNotEmpty) ...[
|
if (extension.permissions.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Permissions'),
|
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -330,8 +331,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
if (extension.settings.isNotEmpty) ...[
|
if (extension.settings.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Settings'),
|
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
|
||||||
),
|
),
|
||||||
if (_isLoadingSettings)
|
if (_isLoadingSettings)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
@@ -364,7 +365,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _confirmRemove(context),
|
onPressed: () => _confirmRemove(context),
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
label: const Text('Remove Extension'),
|
label: Text(context.l10n.extensionRemoveButton),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: colorScheme.error,
|
foregroundColor: colorScheme.error,
|
||||||
side: BorderSide(color: colorScheme.error),
|
side: BorderSide(color: colorScheme.error),
|
||||||
@@ -398,22 +399,21 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Remove Extension'),
|
title: Text(context.l10n.dialogRemoveExtension),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'Are you sure you want to remove this extension? '
|
context.l10n.dialogRemoveExtensionMessage,
|
||||||
'This action cannot be undone.',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: colorScheme.error,
|
backgroundColor: colorScheme.error,
|
||||||
),
|
),
|
||||||
child: const Text('Remove'),
|
child: Text(context.l10n.dialogRemove),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -725,7 +725,7 @@ class _SettingItem extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -735,7 +735,7 @@ class _SettingItem extends StatelessWidget {
|
|||||||
onChanged(newValue);
|
onChanged(newValue);
|
||||||
Navigator.pop(context);
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path_provider/path_provider.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/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||||
@@ -74,7 +75,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Extensions',
|
context.l10n.extensionsTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -123,8 +124,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Provider Priority
|
// Provider Priority
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Provider Priority'),
|
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -137,8 +138,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Installed Extensions
|
// Installed Extensions
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Installed Extensions'),
|
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||||
@@ -160,14 +161,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'No extensions installed',
|
context.l10n.extensionsNoExtensions,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Install .spotiflac-ext files to add new providers',
|
context.l10n.extensionsNoExtensionsSubtitle,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -209,7 +210,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _installExtension,
|
onPressed: _installExtension,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Install Extension'),
|
label: Text(context.l10n.extensionsInstallButton),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -236,8 +237,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Extensions can add new metadata and download providers. '
|
context.l10n.extensionsInfoTip,
|
||||||
'Only install extensions from trusted sources.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -266,8 +266,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Please select a .spotiflac-ext file'),
|
content: Text(context.l10n.snackbarSelectExtFile),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,7 +282,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
String message;
|
String message;
|
||||||
if (success) {
|
if (success) {
|
||||||
message = 'Extension installed successfully';
|
message = context.l10n.extensionsInstalledSuccess;
|
||||||
} else {
|
} else {
|
||||||
// Parse friendly error message
|
// Parse friendly error message
|
||||||
message = _getFriendlyErrorMessage(extState.error);
|
message = _getFriendlyErrorMessage(extState.error);
|
||||||
@@ -404,8 +404,8 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
hasError
|
hasError
|
||||||
? extension.errorMessage ?? 'Error loading extension'
|
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
|
||||||
: 'v${extension.version} by ${extension.author}',
|
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: hasError
|
color: hasError
|
||||||
? colorScheme.error
|
? colorScheme.error
|
||||||
@@ -474,7 +474,7 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Download Priority',
|
context.l10n.extensionsDownloadPriority,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: hasDownloadExtensions
|
color: hasDownloadExtensions
|
||||||
? null
|
? null
|
||||||
@@ -484,8 +484,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
hasDownloadExtensions
|
hasDownloadExtensions
|
||||||
? 'Set download service order'
|
? context.l10n.extensionsDownloadPrioritySubtitle
|
||||||
: 'No extensions with download provider',
|
: context.l10n.extensionsNoDownloadProvider,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -543,7 +543,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Metadata Priority',
|
context.l10n.extensionsMetadataPriority,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: hasMetadataExtensions
|
color: hasMetadataExtensions
|
||||||
? null
|
? null
|
||||||
@@ -553,8 +553,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
hasMetadataExtensions
|
hasMetadataExtensions
|
||||||
? 'Set search & metadata source order'
|
? context.l10n.extensionsMetadataPrioritySubtitle
|
||||||
: 'No extensions with metadata provider',
|
: context.l10n.extensionsNoMetadataProvider,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -590,7 +590,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Get current provider name
|
// Get current provider name
|
||||||
String currentProviderName = 'Default (Deezer/Spotify)';
|
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||||
@@ -619,7 +619,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Search Provider',
|
context.l10n.extensionsSearchProvider,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: searchProviders.isEmpty
|
color: searchProviders.isEmpty
|
||||||
? colorScheme.outline
|
? colorScheme.outline
|
||||||
@@ -629,7 +629,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
searchProviders.isEmpty
|
searchProviders.isEmpty
|
||||||
? 'No extensions with custom search'
|
? context.l10n.extensionsNoCustomSearch
|
||||||
: currentProviderName,
|
: currentProviderName,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -674,7 +674,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Search Provider',
|
ctx.l10n.extensionsSearchProvider,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -683,7 +683,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Choose which service to use for searching tracks',
|
ctx.l10n.extensionsSearchProviderDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -692,8 +692,8 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
// Default option
|
// Default option
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||||
title: const Text('Default (Deezer/Spotify)'),
|
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||||
subtitle: const Text('Use built-in search'),
|
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
@@ -706,7 +706,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
...searchProviders.map((ext) => ListTile(
|
...searchProviders.map((ext) => ListTile(
|
||||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||||
title: Text(ext.displayName),
|
title: Text(ext.displayName),
|
||||||
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
|
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
|
||||||
trailing: settings.searchProvider == ext.id
|
trailing: settings.searchProvider == ext.id
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
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/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
Clipboard.setData(ClipboardData(text: logs));
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Text('Logs copied to clipboard'),
|
content: Text(context.l10n.logCopied),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
@@ -84,19 +85,19 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Clear Logs'),
|
title: Text(context.l10n.logClearLogsTitle),
|
||||||
content: const Text('Are you sure you want to clear all logs?'),
|
content: Text(context.l10n.logClearLogsMessage),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
LogBuffer().clear();
|
LogBuffer().clear();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text('Clear'),
|
child: Text(context.l10n.dialogClear),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -166,19 +167,19 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'share',
|
value: 'share',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.share),
|
leading: const Icon(Icons.share),
|
||||||
title: Text('Share logs'),
|
title: Text(context.l10n.logShareLogs),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'clear',
|
value: 'clear',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: Text('Clear logs'),
|
title: Text(context.l10n.logClearLogs),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -195,7 +196,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Logs',
|
context.l10n.logTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -208,8 +209,8 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Filter section
|
// Filter section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Filter'),
|
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -225,10 +226,10 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
|
Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Filter logs by severity',
|
context.l10n.logFilterBySeverity,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -279,7 +280,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search logs...',
|
hintText: context.l10n.logSearchHint,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -316,7 +317,9 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
// Log entries section
|
// Log entries section
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No logs yet',
|
context.l10n.logNoLogsYet,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Logs will appear here as you use the app',
|
context.l10n.logNoLogsYetSubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/extension_provider.dart';
|
||||||
|
|
||||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||||
@@ -81,7 +82,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _saveChanges,
|
onPressed: _saveChanges,
|
||||||
child: const Text('Save'),
|
child: Text(context.l10n.dialogSave),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
@@ -96,7 +97,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Metadata Priority',
|
context.l10n.metadataProviderPriorityTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -113,8 +114,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Drag to reorder metadata providers. The app will try providers '
|
context.l10n.metadataProviderPriorityDescription,
|
||||||
'from top to bottom when searching for tracks and fetching metadata.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -166,8 +166,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Deezer has no rate limits and is recommended as primary. '
|
context.l10n.metadataProviderPriorityInfo,
|
||||||
'Spotify may rate limit after many requests.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -190,16 +189,16 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Discard Changes?'),
|
title: Text(context.l10n.dialogDiscardChanges),
|
||||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
content: Text(context.l10n.dialogUnsavedChanges),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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;
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
final info = _getProviderInfo(provider);
|
final info = _getProviderInfo(context, provider);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
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) {
|
switch (provider) {
|
||||||
case 'deezer':
|
case 'deezer':
|
||||||
return _MetadataProviderInfo(
|
return _MetadataProviderInfo(
|
||||||
name: 'Deezer',
|
name: 'Deezer',
|
||||||
icon: Icons.album,
|
icon: Icons.album,
|
||||||
description: 'No rate limits',
|
description: context.l10n.metadataNoRateLimits,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
case 'spotify':
|
case 'spotify':
|
||||||
return _MetadataProviderInfo(
|
return _MetadataProviderInfo(
|
||||||
name: 'Spotify',
|
name: 'Spotify',
|
||||||
icon: Icons.music_note,
|
icon: Icons.music_note,
|
||||||
description: 'May rate limit',
|
description: context.l10n.metadataMayRateLimit,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -344,7 +343,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
return _MetadataProviderInfo(
|
return _MetadataProviderInfo(
|
||||||
name: provider,
|
name: provider,
|
||||||
icon: Icons.extension,
|
icon: Icons.extension,
|
||||||
description: 'Extension',
|
description: context.l10n.providerExtension,
|
||||||
isBuiltIn: false,
|
isBuiltIn: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/models/settings.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
@@ -50,7 +51,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
bottom: 16,
|
bottom: 16,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Options',
|
context.l10n.optionsTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -63,8 +64,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Search Source section
|
// Search Source section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Search Source'),
|
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -93,7 +94,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
|
context.l10n.optionsSpotifyWarning,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -107,10 +108,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.key,
|
icon: Icons.key,
|
||||||
title: 'Spotify Credentials',
|
title: context.l10n.optionsSpotifyCredentials,
|
||||||
subtitle: settings.spotifyClientId.isNotEmpty
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId)
|
||||||
: 'Required - tap to configure',
|
: context.l10n.optionsSpotifyCredentialsRequired,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
@@ -130,16 +131,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Download options section
|
// Download options section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Download'),
|
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.sync,
|
icon: Icons.sync,
|
||||||
title: 'Auto Fallback',
|
title: context.l10n.optionsAutoFallback,
|
||||||
subtitle: 'Try other services if download fails',
|
subtitle: context.l10n.optionsAutoFallbackSubtitle,
|
||||||
value: settings.autoFallback,
|
value: settings.autoFallback,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||||
@@ -147,10 +148,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
if (hasExtensions)
|
if (hasExtensions)
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.extension,
|
icon: Icons.extension,
|
||||||
title: 'Use Extension Providers',
|
title: context.l10n.optionsUseExtensionProviders,
|
||||||
subtitle: settings.useExtensionProviders
|
subtitle: settings.useExtensionProviders
|
||||||
? 'Extensions will be tried first'
|
? context.l10n.optionsUseExtensionProvidersOn
|
||||||
: 'Using built-in providers only',
|
: context.l10n.optionsUseExtensionProvidersOff,
|
||||||
value: settings.useExtensionProviders,
|
value: settings.useExtensionProviders,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -158,16 +159,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.lyrics,
|
icon: Icons.lyrics,
|
||||||
title: 'Embed Lyrics',
|
title: context.l10n.optionsEmbedLyrics,
|
||||||
subtitle: 'Embed synced lyrics into FLAC files',
|
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
|
||||||
value: settings.embedLyrics,
|
value: settings.embedLyrics,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.image,
|
icon: Icons.image,
|
||||||
title: 'Max Quality Cover',
|
title: context.l10n.optionsMaxQualityCover,
|
||||||
subtitle: 'Download highest resolution cover art',
|
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
|
||||||
value: settings.maxQualityCover,
|
value: settings.maxQualityCover,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -179,8 +180,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Performance section
|
// Performance section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Performance'),
|
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -196,16 +197,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// App section
|
// App section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'App'),
|
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.store,
|
icon: Icons.store,
|
||||||
title: 'Extension Store',
|
title: context.l10n.optionsExtensionStore,
|
||||||
subtitle: 'Show Store tab in navigation',
|
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
||||||
value: settings.showExtensionStore,
|
value: settings.showExtensionStore,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -213,8 +214,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.system_update,
|
icon: Icons.system_update,
|
||||||
title: 'Check for Updates',
|
title: context.l10n.optionsCheckUpdates,
|
||||||
subtitle: 'Notify when new version is available',
|
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
|
||||||
value: settings.checkForUpdates,
|
value: settings.checkForUpdates,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -230,16 +231,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Data section
|
// Data section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Data'),
|
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.delete_forever,
|
icon: Icons.delete_forever,
|
||||||
title: 'Clear Download History',
|
title: context.l10n.optionsClearHistory,
|
||||||
subtitle: 'Remove all downloaded tracks from history',
|
subtitle: context.l10n.optionsClearHistorySubtitle,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_showClearHistoryDialog(context, ref, colorScheme),
|
_showClearHistoryDialog(context, ref, colorScheme),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
@@ -249,18 +250,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Debug section
|
// Debug section
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Debug'),
|
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.bug_report,
|
icon: Icons.bug_report,
|
||||||
title: 'Detailed Logging',
|
title: context.l10n.optionsDetailedLogging,
|
||||||
subtitle: settings.enableLogging
|
subtitle: settings.enableLogging
|
||||||
? 'Detailed logs are being recorded'
|
? context.l10n.optionsDetailedLoggingOn
|
||||||
: 'Enable for bug reports',
|
: context.l10n.optionsDetailedLoggingOff,
|
||||||
value: settings.enableLogging,
|
value: settings.enableLogging,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setEnableLogging(v),
|
ref.read(settingsProvider.notifier).setEnableLogging(v),
|
||||||
@@ -285,14 +286,14 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Clear History'),
|
title: Text(context.l10n.dialogClearHistoryTitle),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'Are you sure you want to clear all download history? This cannot be undone.',
|
context.l10n.dialogClearHistoryMessage,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -300,9 +301,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
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(
|
Text(
|
||||||
'Spotify Credentials',
|
context.l10n.credentialsTitle,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -361,7 +362,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Enter your Client ID and Secret to use your own Spotify application quota.',
|
context.l10n.credentialsDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -373,8 +374,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
TextField(
|
TextField(
|
||||||
controller: clientIdController,
|
controller: clientIdController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Client ID',
|
labelText: context.l10n.credentialsClientId,
|
||||||
hintText: 'Paste Client ID',
|
hintText: context.l10n.credentialsClientIdHint,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||||
alpha: 0.3,
|
alpha: 0.3,
|
||||||
@@ -412,8 +413,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
controller: clientSecretController,
|
controller: clientSecretController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Client Secret',
|
labelText: context.l10n.credentialsClientSecret,
|
||||||
hintText: 'Paste Client Secret',
|
hintText: context.l10n.credentialsClientSecretHint,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||||
alpha: 0.3,
|
alpha: 0.3,
|
||||||
@@ -458,12 +459,12 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
.setSpotifyCredentials(clientId, clientSecret);
|
.setSpotifyCredentials(clientId, clientSecret);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Credentials saved')),
|
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Please fill all fields'),
|
content: Text(context.l10n.snackbarFillAllFields),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -474,9 +475,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Save Credentials',
|
context.l10n.actionSaveCredentials,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -489,14 +490,14 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
.clearSpotifyCredentials();
|
.clearSpotifyCredentials();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Credentials cleared')),
|
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: colorScheme.error,
|
foregroundColor: colorScheme.error,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Concurrent Downloads',
|
context.l10n.optionsConcurrentDownloads,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
currentValue == 1
|
currentValue == 1
|
||||||
? 'Sequential (1 at a time)'
|
? context.l10n.optionsConcurrentSequential
|
||||||
: '$currentValue parallel downloads',
|
: context.l10n.optionsConcurrentParallel(currentValue),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -590,7 +591,7 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Parallel downloads may trigger rate limiting',
|
context.l10n.optionsConcurrentWarning,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||||
@@ -682,14 +683,14 @@ class _UpdateChannelSelector extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Update Channel',
|
context.l10n.optionsUpdateChannel,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
currentChannel == 'preview'
|
currentChannel == 'preview'
|
||||||
? 'Get preview releases'
|
? context.l10n.optionsUpdateChannelPreview
|
||||||
: 'Stable releases only',
|
: context.l10n.optionsUpdateChannelStable,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -703,13 +704,13 @@ class _UpdateChannelSelector extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_ChannelChip(
|
_ChannelChip(
|
||||||
label: 'Stable',
|
label: context.l10n.channelStable,
|
||||||
isSelected: currentChannel == 'stable',
|
isSelected: currentChannel == 'stable',
|
||||||
onTap: () => onChanged('stable'),
|
onTap: () => onChanged('stable'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ChannelChip(
|
_ChannelChip(
|
||||||
label: 'Preview',
|
label: context.l10n.channelPreview,
|
||||||
isSelected: currentChannel == 'preview',
|
isSelected: currentChannel == 'preview',
|
||||||
onTap: () => onChanged('preview'),
|
onTap: () => onChanged('preview'),
|
||||||
),
|
),
|
||||||
@@ -726,7 +727,7 @@ class _UpdateChannelSelector extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Preview may contain bugs or incomplete features',
|
context.l10n.optionsUpdateChannelWarning,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -823,7 +824,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Primary Provider',
|
context.l10n.optionsPrimaryProvider,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||||
@@ -831,8 +832,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
hasExtensionSearch
|
hasExtensionSearch
|
||||||
? 'Using extension: $extensionName'
|
? context.l10n.optionsUsingExtension(extensionName!)
|
||||||
: 'Service used when searching by track name.',
|
: context.l10n.optionsPrimaryProviderSubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: hasExtensionSearch
|
color: hasExtensionSearch
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
@@ -883,7 +884,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Tap Deezer or Spotify to switch back from extension',
|
context.l10n.optionsSwitchBack,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -903,16 +904,12 @@ class _SourceChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? badge;
|
|
||||||
final Color? badgeColor;
|
|
||||||
|
|
||||||
const _SourceChip({
|
const _SourceChip({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.badge,
|
|
||||||
this.badgeColor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -958,24 +955,6 @@ class _SourceChip extends StatelessWidget {
|
|||||||
: colorScheme.onSurfaceVariant,
|
: 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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/extension_provider.dart';
|
||||||
|
|
||||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||||
@@ -82,7 +83,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _saveChanges,
|
onPressed: _saveChanges,
|
||||||
child: const Text('Save'),
|
child: Text(context.l10n.dialogSave),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
@@ -97,7 +98,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Provider Priority',
|
context.l10n.providerPriorityTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -114,8 +115,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Drag to reorder download providers. The app will try providers '
|
context.l10n.providerPriorityDescription,
|
||||||
'from top to bottom when downloading tracks.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -167,8 +167,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'If a track is not available on the first provider, '
|
context.l10n.providerPriorityInfo,
|
||||||
'the app will automatically try the next one.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -191,16 +190,16 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Discard Changes?'),
|
title: Text(context.l10n.dialogDiscardChanges),
|
||||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
content: Text(context.l10n.dialogUnsavedChanges),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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(
|
Text(
|
||||||
info.isBuiltIn ? 'Built-in' : 'Extension',
|
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.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/appearance_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||||
@@ -41,7 +42,7 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Settings',
|
context.l10n.settingsTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -55,57 +56,67 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
|
|
||||||
// First group: Appearance & Download
|
// First group: Appearance & Download
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: Builder(
|
||||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
builder: (context) {
|
||||||
children: [
|
final l10n = context.l10n;
|
||||||
SettingsItem(
|
return SettingsGroup(
|
||||||
icon: Icons.palette_outlined,
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
title: 'Appearance',
|
children: [
|
||||||
subtitle: 'Theme, colors, display',
|
SettingsItem(
|
||||||
onTap: () =>
|
icon: Icons.palette_outlined,
|
||||||
_navigateTo(context, const AppearanceSettingsPage()),
|
title: l10n.settingsAppearance,
|
||||||
),
|
subtitle: l10n.settingsAppearanceSubtitle,
|
||||||
SettingsItem(
|
onTap: () =>
|
||||||
icon: Icons.download_outlined,
|
_navigateTo(context, const AppearanceSettingsPage()),
|
||||||
title: 'Download',
|
),
|
||||||
subtitle: 'Service, quality, filename format',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
icon: Icons.download_outlined,
|
||||||
),
|
title: l10n.settingsDownload,
|
||||||
SettingsItem(
|
subtitle: l10n.settingsDownloadSubtitle,
|
||||||
icon: Icons.tune_outlined,
|
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||||
title: 'Options',
|
),
|
||||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
icon: Icons.tune_outlined,
|
||||||
),
|
title: l10n.settingsOptions,
|
||||||
SettingsItem(
|
subtitle: l10n.settingsOptionsSubtitle,
|
||||||
icon: Icons.extension_outlined,
|
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||||
title: 'Extensions',
|
),
|
||||||
subtitle: 'Manage download providers',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
icon: Icons.extension_outlined,
|
||||||
showDivider: false,
|
title: l10n.settingsExtensions,
|
||||||
),
|
subtitle: l10n.settingsExtensionsSubtitle,
|
||||||
],
|
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Second group: Logs & About
|
// Second group: Logs & About
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: Builder(
|
||||||
children: [
|
builder: (context) {
|
||||||
SettingsItem(
|
final l10n = context.l10n;
|
||||||
icon: Icons.article_outlined,
|
return SettingsGroup(
|
||||||
title: 'Logs',
|
children: [
|
||||||
subtitle: 'View app logs for debugging',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const LogScreen()),
|
icon: Icons.article_outlined,
|
||||||
),
|
title: l10n.logTitle,
|
||||||
SettingsItem(
|
subtitle: l10n.settingsLogsSubtitle,
|
||||||
icon: Icons.info_outline,
|
onTap: () => _navigateTo(context, const LogScreen()),
|
||||||
title: 'About',
|
),
|
||||||
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const AboutPage()),
|
icon: Icons.info_outline,
|
||||||
showDivider: false,
|
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) {
|
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(
|
Navigator.of(context).push(
|
||||||
// Use PageRouteBuilder for better predictive back gesture support
|
// Use PageRouteBuilder for better predictive back gesture support
|
||||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
// 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:go_router/go_router.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
|
||||||
class SetupScreen extends ConsumerStatefulWidget {
|
class SetupScreen extends ConsumerStatefulWidget {
|
||||||
const SetupScreen({super.key});
|
const SetupScreen({super.key});
|
||||||
@@ -123,19 +124,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
final shouldOpen = await showDialog<bool>(
|
final shouldOpen = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Storage Access Required'),
|
title: Text(context.l10n.setupStorageAccessRequired),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
|
'${context.l10n.setupStorageAccessMessage}\n\n'
|
||||||
'Please enable "Allow access to manage all files" in the next screen.',
|
'${context.l10n.setupAllowAccessToManageFiles}',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
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>(
|
final shouldOpen = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Storage Access Required'),
|
title: Text(context.l10n.setupStorageAccessRequired),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'Android 11+ requires "All files access" permission to save music files.\n\n'
|
'${context.l10n.setupStorageAccessMessageAndroid11}\n\n'
|
||||||
'Please enable "Allow access to manage all files" in the next screen.',
|
'${context.l10n.setupAllowAccessToManageFiles}',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
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 {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text('$permissionType Permission Required'),
|
title: Text(context.l10n.setupPermissionRequired(permissionType)),
|
||||||
content: Text(
|
content: Text(
|
||||||
'$permissionType permission is required for the best experience. '
|
context.l10n.setupPermissionRequiredMessage(permissionType),
|
||||||
'Please grant permission in app settings.',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
openAppSettings();
|
openAppSettings();
|
||||||
},
|
},
|
||||||
child: const Text('Open Settings'),
|
child: Text(context.l10n.setupOpenSettings),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -288,7 +288,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
// Android: Use file picker
|
||||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||||
dialogTitle: 'Select Download Folder',
|
dialogTitle: context.l10n.setupSelectDownloadFolder,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedDirectory != null) {
|
if (selectedDirectory != null) {
|
||||||
@@ -299,11 +299,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
final useDefault = await showDialog<bool>(
|
final useDefault = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Use Default Folder?'),
|
title: Text(context.l10n.setupUseDefaultFolder),
|
||||||
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel)),
|
||||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -333,19 +333,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
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(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
title: const Text('App Documents Folder'),
|
title: Text(context.l10n.setupAppDocumentsFolder),
|
||||||
subtitle: const Text('Recommended - accessible via Files app'),
|
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
|
||||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final dir = await _getDefaultDirectory();
|
final dir = await _getDefaultDirectory();
|
||||||
@@ -355,8 +355,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
title: const Text('Choose from Files'),
|
title: Text(context.l10n.setupChooseFromFiles),
|
||||||
subtitle: const Text('Select iCloud or other location'),
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// 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),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -486,16 +486,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text('SpotiFLAC',
|
Text(context.l10n.appName,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('Download Spotify tracks in FLAC',
|
Text(context.l10n.setupDownloadInFlac,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant)),
|
color: colorScheme.onSurfaceVariant)),
|
||||||
],
|
],
|
||||||
@@ -529,8 +529,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||||
final steps = _androidSdkVersion >= 33
|
final steps = _androidSdkVersion >= 33
|
||||||
? ['Storage', 'Notification', 'Folder', 'Spotify']
|
? [context.l10n.setupStepStorage, context.l10n.setupStepNotification, context.l10n.setupStepFolder, context.l10n.setupStepSpotify]
|
||||||
: ['Permission', 'Folder', 'Spotify'];
|
: [context.l10n.setupStepPermission, context.l10n.setupStepFolder, context.l10n.setupStepSpotify];
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -653,7 +653,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
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),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -662,8 +662,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
_storagePermissionGranted
|
_storagePermissionGranted
|
||||||
? 'You can now proceed to the next step.'
|
? context.l10n.setupProceedToNextStep
|
||||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
: context.l10n.setupStorageDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -676,7 +676,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.security_rounded),
|
: const Icon(Icons.security_rounded),
|
||||||
label: const Text('Grant Permission'),
|
label: Text(context.l10n.setupGrantPermission),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
@@ -707,7 +707,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
_notificationPermissionGranted ? context.l10n.setupNotificationGranted : context.l10n.setupNotificationEnable,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -716,8 +716,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
_notificationPermissionGranted
|
_notificationPermissionGranted
|
||||||
? 'You will receive download progress notifications.'
|
? context.l10n.setupNotificationProgressDescription
|
||||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
: context.l10n.setupNotificationBackgroundDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -730,7 +730,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.notifications_active_rounded),
|
: const Icon(Icons.notifications_active_rounded),
|
||||||
label: const Text('Enable Notifications'),
|
label: Text(context.l10n.setupEnableNotifications),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
@@ -742,7 +742,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
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),
|
const SizedBox(height: 20),
|
||||||
Text(
|
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),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -802,7 +802,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -814,7 +814,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
|
: 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(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
@@ -845,7 +845,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
'Spotify API (Optional)',
|
context.l10n.setupSpotifyApiOptional,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -853,7 +853,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -868,9 +868,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: SwitchListTile(
|
child: SwitchListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
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(
|
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),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
secondary: Container(
|
secondary: Container(
|
||||||
@@ -907,12 +907,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Client ID
|
// 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),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _clientIdController,
|
controller: _clientIdController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter Spotify Client ID',
|
hintText: context.l10n.setupEnterClientId,
|
||||||
prefixIcon: const Icon(Icons.key_rounded),
|
prefixIcon: const Icon(Icons.key_rounded),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -926,13 +926,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Client Secret
|
// 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),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _clientSecretController,
|
controller: _clientSecretController,
|
||||||
obscureText: !_showClientSecret,
|
obscureText: !_showClientSecret,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter Spotify Client Secret',
|
hintText: context.l10n.setupEnterClientSecret,
|
||||||
prefixIcon: const Icon(Icons.lock_rounded),
|
prefixIcon: const Icon(Icons.lock_rounded),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||||
@@ -962,7 +962,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Get credentials from developer.spotify.com',
|
context.l10n.setupGetCredentialsFromSpotify,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -995,7 +995,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
label: const Text('Back'),
|
label: Text(context.l10n.setupBack),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
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),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
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
|
else
|
||||||
@@ -1029,7 +1029,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
: Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
|
Text(_useSpotifyApi ? context.l10n.setupGetStarted : context.l10n.setupSkipAndStart),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Icon(Icons.check_rounded, size: 18),
|
const Icon(Icons.check_rounded, size: 18),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.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/store_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ class _ExtensionDetailsScreenState
|
|||||||
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
|
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
|
||||||
_buildSectionHeader(
|
_buildSectionHeader(
|
||||||
context,
|
context,
|
||||||
'About',
|
context.l10n.aboutTitle,
|
||||||
Icons.info_outline,
|
Icons.info_outline,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
),
|
),
|
||||||
@@ -61,7 +62,7 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
_buildSectionHeader(
|
_buildSectionHeader(
|
||||||
context,
|
context,
|
||||||
'Capabilities',
|
context.l10n.extensionCapabilities,
|
||||||
Icons.extension_outlined,
|
Icons.extension_outlined,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
),
|
),
|
||||||
@@ -173,9 +174,9 @@ class _ExtensionDetailsScreenState
|
|||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'by ${ext.author}',
|
context.l10n.extensionsAuthor(ext.author),
|
||||||
style: Theme.of(context).textTheme.bodyLarge
|
style: Theme.of(context).textTheme.bodyLarge
|
||||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
@@ -204,7 +205,7 @@ class _ExtensionDetailsScreenState
|
|||||||
),
|
),
|
||||||
if (ext.isInstalled)
|
if (ext.isInstalled)
|
||||||
_Badge(
|
_Badge(
|
||||||
label: 'Installed',
|
label: context.l10n.storeInstalled,
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primaryContainer,
|
||||||
textColor: colorScheme.onPrimaryContainer,
|
textColor: colorScheme.onPrimaryContainer,
|
||||||
icon: Icons.check,
|
icon: Icons.check,
|
||||||
@@ -226,7 +227,7 @@ class _ExtensionDetailsScreenState
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _updateExtension(ext),
|
onPressed: () => _updateExtension(ext),
|
||||||
icon: const Icon(Icons.update),
|
icon: const Icon(Icons.update),
|
||||||
label: Text('Update to v${ext.version}'),
|
label: Text('${context.l10n.storeUpdate} v${ext.version}'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(52),
|
minimumSize: const Size.fromHeight(52),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -241,7 +242,7 @@ class _ExtensionDetailsScreenState
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: null,
|
onPressed: null,
|
||||||
icon: const Icon(Icons.check),
|
icon: const Icon(Icons.check),
|
||||||
label: const Text('Installed'),
|
label: Text(context.l10n.storeInstalled),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize: const Size(0, 52),
|
minimumSize: const Size(0, 52),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -262,7 +263,7 @@ class _ExtensionDetailsScreenState
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tooltip: 'Uninstall',
|
tooltip: context.l10n.extensionsUninstall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -270,7 +271,7 @@ class _ExtensionDetailsScreenState
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _installExtension(ext),
|
onPressed: () => _installExtension(ext),
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download),
|
||||||
label: const Text('Install Extension'),
|
label: Text(context.l10n.storeInstall),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(52),
|
minimumSize: const Size.fromHeight(52),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -380,19 +381,19 @@ class _ExtensionDetailsScreenState
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'Updated',
|
label: context.l10n.extensionUpdated,
|
||||||
value: ext.updatedAt.isNotEmpty
|
value: ext.updatedAt.isNotEmpty
|
||||||
? _formatDate(ext.updatedAt)
|
? _formatDate(context, ext.updatedAt)
|
||||||
: '-',
|
: '-',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'ID',
|
label: context.l10n.extensionId,
|
||||||
value: ext.id,
|
value: ext.id,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'Min App Version',
|
label: context.l10n.extensionMinAppVersion,
|
||||||
value: ext.minAppVersion ?? 'Any',
|
value: ext.minAppVersion ?? 'Any',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
isLast: true,
|
isLast: true,
|
||||||
@@ -428,19 +429,19 @@ class _ExtensionDetailsScreenState
|
|||||||
children: [
|
children: [
|
||||||
_CapabilityRow(
|
_CapabilityRow(
|
||||||
icon: Icons.search,
|
icon: Icons.search,
|
||||||
label: 'Metadata Provider',
|
label: context.l10n.extensionMetadataProvider,
|
||||||
enabled: isMetadataProvider,
|
enabled: isMetadataProvider,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_CapabilityRow(
|
_CapabilityRow(
|
||||||
icon: Icons.download,
|
icon: Icons.download,
|
||||||
label: 'Download Provider',
|
label: context.l10n.extensionDownloadProvider,
|
||||||
enabled: isDownloadProvider,
|
enabled: isDownloadProvider,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_CapabilityRow(
|
_CapabilityRow(
|
||||||
icon: Icons.lyrics,
|
icon: Icons.lyrics,
|
||||||
label: 'Lyrics Provider',
|
label: context.l10n.extensionLyricsProvider,
|
||||||
enabled: isLyricsProvider,
|
enabled: isLyricsProvider,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
@@ -458,22 +459,22 @@ class _ExtensionDetailsScreenState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDate(String dateStr) {
|
String _formatDate(BuildContext context, String dateStr) {
|
||||||
try {
|
try {
|
||||||
final date = DateTime.parse(dateStr);
|
final date = DateTime.parse(dateStr);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final diff = now.difference(date);
|
final diff = now.difference(date);
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
if (diff.inDays == 0) {
|
||||||
return 'Today';
|
return context.l10n.dateToday;
|
||||||
} else if (diff.inDays == 1) {
|
} else if (diff.inDays == 1) {
|
||||||
return 'Yesterday';
|
return context.l10n.dateYesterday;
|
||||||
} else if (diff.inDays < 7) {
|
} else if (diff.inDays < 7) {
|
||||||
return '${diff.inDays} days ago';
|
return context.l10n.dateDaysAgo(diff.inDays);
|
||||||
} else if (diff.inDays < 30) {
|
} 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) {
|
} else if (diff.inDays < 365) {
|
||||||
return '${(diff.inDays / 30).floor()} months ago';
|
return context.l10n.dateMonthsAgo((diff.inDays / 30).floor());
|
||||||
} else {
|
} else {
|
||||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
@@ -530,8 +531,8 @@ class _ExtensionDetailsScreenState
|
|||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
success
|
success
|
||||||
? '${ext.displayName} installed.'
|
? context.l10n.snackbarExtensionInstalled(ext.displayName)
|
||||||
: 'Failed to install ${ext.displayName}',
|
: context.l10n.snackbarFailedToInstall,
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -551,8 +552,8 @@ class _ExtensionDetailsScreenState
|
|||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
success
|
success
|
||||||
? '${ext.displayName} updated.'
|
? context.l10n.snackbarExtensionUpdated(ext.displayName)
|
||||||
: 'Failed to update ${ext.displayName}',
|
: context.l10n.snackbarFailedToUpdate,
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -564,17 +565,17 @@ class _ExtensionDetailsScreenState
|
|||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Uninstall Extension?'),
|
title: Text(context.l10n.dialogUninstallExtension),
|
||||||
content: Text('Are you sure you want to remove ${ext.displayName}?'),
|
content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Uninstall',
|
context.l10n.dialogUninstall,
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.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/store_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||||
@@ -74,7 +75,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Store',
|
context.l10n.storeTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (14 * expandRatio),
|
fontSize: 20 + (14 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -93,7 +94,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search extensions...',
|
hintText: context.l10n.storeSearch,
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
@@ -141,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: 'All',
|
label: context.l10n.storeFilterAll,
|
||||||
icon: Icons.apps,
|
icon: Icons.apps,
|
||||||
isSelected: state.selectedCategory == null,
|
isSelected: state.selectedCategory == null,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
@@ -149,7 +150,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: 'Metadata',
|
label: context.l10n.storeFilterMetadata,
|
||||||
icon: Icons.label_outline,
|
icon: Icons.label_outline,
|
||||||
isSelected:
|
isSelected:
|
||||||
state.selectedCategory == StoreCategory.metadata,
|
state.selectedCategory == StoreCategory.metadata,
|
||||||
@@ -159,7 +160,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: 'Download',
|
label: context.l10n.storeFilterDownload,
|
||||||
icon: Icons.download_outlined,
|
icon: Icons.download_outlined,
|
||||||
isSelected:
|
isSelected:
|
||||||
state.selectedCategory == StoreCategory.download,
|
state.selectedCategory == StoreCategory.download,
|
||||||
@@ -169,7 +170,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: 'Utility',
|
label: context.l10n.storeFilterUtility,
|
||||||
icon: Icons.build_outlined,
|
icon: Icons.build_outlined,
|
||||||
isSelected:
|
isSelected:
|
||||||
state.selectedCategory == StoreCategory.utility,
|
state.selectedCategory == StoreCategory.utility,
|
||||||
@@ -179,7 +180,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: 'Lyrics',
|
label: context.l10n.storeFilterLyrics,
|
||||||
icon: Icons.lyrics_outlined,
|
icon: Icons.lyrics_outlined,
|
||||||
isSelected:
|
isSelected:
|
||||||
state.selectedCategory == StoreCategory.lyrics,
|
state.selectedCategory == StoreCategory.lyrics,
|
||||||
@@ -189,7 +190,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: 'Integration',
|
label: context.l10n.storeFilterIntegration,
|
||||||
icon: Icons.link,
|
icon: Icons.link,
|
||||||
isSelected:
|
isSelected:
|
||||||
state.selectedCategory == StoreCategory.integration,
|
state.selectedCategory == StoreCategory.integration,
|
||||||
@@ -286,7 +287,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||||
icon: const Icon(Icons.refresh),
|
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();
|
_searchController.clear();
|
||||||
ref.read(storeProvider.notifier).clearSearch();
|
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,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
// Warning badge for incompatible extensions
|
||||||
Text(
|
if (extension.requiresNewerApp) ...[
|
||||||
extension.description,
|
const SizedBox(height: 4),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
Container(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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,
|
] else ...[
|
||||||
overflow: TextOverflow.ellipsis,
|
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),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
),
|
),
|
||||||
child: const Text('Update'),
|
child: Text(context.l10n.storeUpdate),
|
||||||
)
|
)
|
||||||
else if (extension.isInstalled)
|
else if (extension.isInstalled)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -602,7 +629,7 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
minimumSize: const Size(0, 36),
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:open_filex/open_filex.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:url_launcher/url_launcher.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.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
|
/// Screen to display detailed metadata for a downloaded track
|
||||||
/// Designed with Material Expressive 3 style
|
/// Designed with Material Expressive 3 style
|
||||||
@@ -27,6 +29,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bool _lyricsLoading = false;
|
bool _lyricsLoading = false;
|
||||||
String? _lyricsError;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -68,7 +78,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
String get trackName => item.trackName;
|
String get trackName => item.trackName;
|
||||||
String get artistName => item.artistName;
|
String get artistName => item.artistName;
|
||||||
String get albumName => item.albumName;
|
String get albumName => item.albumName;
|
||||||
String? get albumArtist => item.albumArtist;
|
String? get albumArtist => _normalizeOptionalString(item.albumArtist);
|
||||||
int? get trackNumber => item.trackNumber;
|
int? get trackNumber => item.trackNumber;
|
||||||
int? get discNumber => item.discNumber;
|
int? get discNumber => item.discNumber;
|
||||||
String? get releaseDate => item.releaseDate;
|
String? get releaseDate => item.releaseDate;
|
||||||
@@ -316,7 +326,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'File not found',
|
context.l10n.trackFileNotFound,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -352,7 +362,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Metadata',
|
context.l10n.trackMetadata,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -374,7 +384,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return OutlinedButton.icon(
|
return OutlinedButton.icon(
|
||||||
onPressed: () => _openServiceUrl(context),
|
onPressed: () => _openServiceUrl(context),
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
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(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -431,7 +441,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
_copyToClipboard(context, webUrl);
|
_copyToClipboard(context, webUrl);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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>[
|
final items = <_MetadataItem>[
|
||||||
_MetadataItem('Track name', trackName),
|
_MetadataItem(context.l10n.trackTrackName, trackName),
|
||||||
_MetadataItem('Artist', artistName),
|
_MetadataItem(context.l10n.trackArtist, artistName),
|
||||||
if (albumArtist != null && albumArtist != artistName)
|
if (albumArtist != null && albumArtist != artistName)
|
||||||
_MetadataItem('Album artist', albumArtist!),
|
_MetadataItem(context.l10n.trackAlbumArtist, albumArtist!),
|
||||||
_MetadataItem('Album', albumName),
|
_MetadataItem(context.l10n.trackAlbum, albumName),
|
||||||
if (trackNumber != null && trackNumber! > 0)
|
if (trackNumber != null && trackNumber! > 0)
|
||||||
_MetadataItem('Track number', trackNumber.toString()),
|
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
||||||
if (discNumber != null && discNumber! > 0)
|
if (discNumber != null && discNumber! > 0)
|
||||||
_MetadataItem('Disc number', discNumber.toString()),
|
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
||||||
if (item.duration != null)
|
if (item.duration != null)
|
||||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
_MetadataItem(context.l10n.trackDuration, _formatDuration(item.duration!)),
|
||||||
if (audioQualityStr != null)
|
if (audioQualityStr != null)
|
||||||
_MetadataItem('Audio quality', audioQualityStr),
|
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
|
||||||
if (releaseDate != null && releaseDate!.isNotEmpty)
|
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||||
_MetadataItem('Release date', releaseDate!),
|
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
|
||||||
if (isrc != null && isrc!.isNotEmpty)
|
if (isrc != null && isrc!.isNotEmpty)
|
||||||
_MetadataItem('ISRC', isrc!),
|
_MetadataItem('ISRC', isrc!),
|
||||||
];
|
];
|
||||||
@@ -473,8 +483,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
items.addAll([
|
items.addAll([
|
||||||
_MetadataItem('Service', item.service.toUpperCase()),
|
_MetadataItem(context.l10n.trackMetadataService, item.service.toUpperCase()),
|
||||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(item.downloadedAt)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -548,7 +558,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'File Info',
|
context.l10n.trackFileInfo,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -699,7 +709,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Lyrics',
|
context.l10n.trackLyrics,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -710,7 +720,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.copy, size: 20),
|
icon: const Icon(Icons.copy, size: 20),
|
||||||
onPressed: () => _copyToClipboard(context, _lyrics!),
|
onPressed: () => _copyToClipboard(context, _lyrics!),
|
||||||
tooltip: 'Copy lyrics',
|
tooltip: context.l10n.trackCopyLyrics,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -742,7 +752,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _fetchLyrics,
|
onPressed: _fetchLyrics,
|
||||||
child: const Text('Retry'),
|
child: Text(context.l10n.dialogRetry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -765,7 +775,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: FilledButton.tonalIcon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: _fetchLyrics,
|
onPressed: _fetchLyrics,
|
||||||
icon: const Icon(Icons.download),
|
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 (mounted) {
|
||||||
if (result.isEmpty) {
|
if (result.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = 'Lyrics not available for this track';
|
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -812,8 +822,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final errorMsg = e.toString().contains('TimeoutException')
|
final errorMsg = e.toString().contains('TimeoutException')
|
||||||
? 'Request timed out. Try again later.'
|
? context.l10n.trackLyricsTimeout
|
||||||
: 'Failed to load lyrics';
|
: context.l10n.trackLyricsLoadFailed;
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = errorMsg;
|
_lyricsError = errorMsg;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
@@ -847,7 +857,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
|
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
label: const Text('Play'),
|
label: Text(context.l10n.trackMetadataPlay),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -863,7 +873,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
||||||
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
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(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -899,7 +909,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: const Text('Copy file path'),
|
title: Text(context.l10n.trackCopyFilePath),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_copyToClipboard(context, cleanFilePath);
|
_copyToClipboard(context, cleanFilePath);
|
||||||
@@ -907,7 +917,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.share),
|
leading: const Icon(Icons.share),
|
||||||
title: const Text('Share'),
|
title: Text(context.l10n.trackMetadataShare),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_shareFile(context);
|
_shareFile(context);
|
||||||
@@ -915,7 +925,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.delete, color: colorScheme.error),
|
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: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_confirmDelete(context, ref, colorScheme);
|
_confirmDelete(context, ref, colorScheme);
|
||||||
@@ -932,14 +942,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Remove from device?'),
|
title: Text(context.l10n.trackDeleteConfirmTitle),
|
||||||
content: const Text(
|
content: Text(context.l10n.trackDeleteConfirmMessage),
|
||||||
'This will permanently delete the downloaded file and remove it from your history.',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -961,7 +969,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
Navigator.pop(context); // Go back to history
|
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 {
|
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||||
try {
|
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) {
|
if (result.type != ResultType.done && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Cannot open: ${result.message}')),
|
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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) {
|
void _copyToClipboard(BuildContext context, String text) {
|
||||||
Clipboard.setData(ClipboardData(text: text));
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Copied to clipboard'),
|
content: Text(context.l10n.trackCopiedToClipboard),
|
||||||
duration: Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1000,7 +1009,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('File not found')),
|
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
|
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
|
/// Set download directory
|
||||||
static Future<void> setDownloadDirectory(String path) async {
|
static Future<void> setDownloadDirectory(String path) async {
|
||||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||||
@@ -787,6 +792,60 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
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 ====================
|
// ==================== EXTENSION POST-PROCESSING ====================
|
||||||
|
|
||||||
/// Run post-processing hooks on a file
|
/// 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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
|
||||||
/// Built-in service info with quality options
|
/// Built-in service info with quality options
|
||||||
class BuiltInService {
|
class BuiltInService {
|
||||||
@@ -167,7 +168,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Download From',
|
context.l10n.downloadFrom,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -202,7 +203,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Select Quality',
|
context.l10n.downloadSelectQuality,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -212,7 +213,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
child: Text(
|
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(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontStyle: FontStyle.italic,
|
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/update_checker.dart';
|
||||||
import 'package:spotiflac_android/services/apk_downloader.dart';
|
import 'package:spotiflac_android/services/apk_downloader.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
|
||||||
class UpdateDialog extends StatefulWidget {
|
class UpdateDialog extends StatefulWidget {
|
||||||
final UpdateInfo updateInfo;
|
final UpdateInfo updateInfo;
|
||||||
@@ -42,7 +43,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isDownloading = true;
|
_isDownloading = true;
|
||||||
_progress = 0;
|
_progress = 0;
|
||||||
_statusText = 'Starting download...';
|
_statusText = context.l10n.updateStartingDownload;
|
||||||
});
|
});
|
||||||
|
|
||||||
final notificationService = NotificationService();
|
final notificationService = NotificationService();
|
||||||
@@ -91,11 +92,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDownloading = false;
|
_isDownloading = false;
|
||||||
_statusText = 'Download failed';
|
_statusText = context.l10n.updateDownloadFailed;
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
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(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
|
_VersionChip(version: AppInfo.version, label: context.l10n.updateCurrent, colorScheme: colorScheme),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
|
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 12),
|
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),
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
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),
|
const SizedBox(height: 12),
|
||||||
@@ -209,7 +210,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
// Changelog section
|
// 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),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxHeight: 180),
|
constraints: const BoxConstraints(maxHeight: 180),
|
||||||
@@ -240,7 +241,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -251,7 +252,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _downloadAndInstall,
|
onPressed: _downloadAndInstall,
|
||||||
icon: const Icon(Icons.download_rounded, size: 20),
|
icon: const Icon(Icons.download_rounded, size: 20),
|
||||||
label: const Text('Download & Install'),
|
label: Text(context.l10n.updateDownloadInstall),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
@@ -271,7 +272,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(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),
|
const SizedBox(width: 8),
|
||||||
@@ -285,7 +286,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.0.3"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -488,6 +493,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.7.2"
|
version: "4.7.2"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.2"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.0.0+57
|
version: 3.1.0+59
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -10,6 +10,11 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
# Localization
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
intl: any
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
riverpod_annotation: ^4.0.0
|
riverpod_annotation: ^4.0.0
|
||||||
@@ -22,7 +27,7 @@ dependencies:
|
|||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.4.0
|
http: ^1.6.0
|
||||||
dio: ^5.8.0
|
dio: ^5.8.0
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
@@ -38,7 +43,7 @@ dependencies:
|
|||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
# File Picker
|
# File Picker
|
||||||
file_picker: ^10.3.0
|
file_picker: ^10.3.8
|
||||||
|
|
||||||
# JSON Serialization
|
# JSON Serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
@@ -77,6 +82,7 @@ flutter_launcher_icons:
|
|||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
generate: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.0.0+57
|
version: 3.1.0+59
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -10,6 +10,11 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
# Localization
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
intl: any
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
riverpod_annotation: ^4.0.0
|
riverpod_annotation: ^4.0.0
|
||||||
@@ -22,7 +27,7 @@ dependencies:
|
|||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.4.0
|
http: ^1.6.0
|
||||||
dio: ^5.8.0
|
dio: ^5.8.0
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
@@ -38,7 +43,7 @@ dependencies:
|
|||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
# File Picker
|
# File Picker
|
||||||
file_picker: ^10.3.0
|
file_picker: ^10.3.8
|
||||||
|
|
||||||
# JSON Serialization
|
# JSON Serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
@@ -77,6 +82,7 @@ flutter_launcher_icons:
|
|||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
generate: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|||||||