Compare commits

..

13 Commits

Author SHA1 Message Date
zarzet 18bc079632 Merge dev into main: v3.0.0 stable release 2026-01-14 18:08:30 +07:00
zarzet 4091a9c499 release: v3.0.0 stable with Extension System 2026-01-14 01:57:30 +07:00
zarzet 9346f2d149 fix: bottom overflow in Folder Organization dialog 2026-01-14 01:00:52 +07:00
zarzet 8ab52959e8 refactor: simplify parallel download result handling in tidal/qobuz 2026-01-14 00:57:04 +07:00
zarzet bad95e99c8 fix: remove unused getDownloadURLSequential from tidal.go
Replaced by parallel version for faster API responses
2026-01-14 00:39:59 +07:00
zarzet dbd7fd70be fix: remove unused function and fix bit shifting warnings
- Remove unused getQobuzDownloadURLSequential (replaced by parallel version)
- Fix bit shifting on byte values in metadata.go (cast to uint32 before shift)
2026-01-14 00:38:46 +07:00
zarzet 125d070cfe fix: remove duplicate --- separator in release notes
Extract changelog now strips trailing --- from CHANGELOG.md sections
2026-01-13 23:51:59 +07:00
zarzet 15acf181d1 fix: back gesture freeze on Android 13+ and add album folder structure setting
- Add PopScope with canPop:true to all settings pages for predictive back gesture support
- Change settings navigation to use PageRouteBuilder instead of MaterialPageRoute
- Add album folder structure setting (artist_album vs album_only)
- Fix extension search result parsing to handle both array and object formats
- Update CHANGELOG

Fixes back gesture freeze issue on OnePlus and other Android 13+ devices with gesture navigation
2026-01-13 23:48:02 +07:00
zarzet e049f9b868 fix: improve artist matching for multi-artist tracks and add cover logging 2026-01-13 20:55:46 +07:00
zarzet 6a886c5276 fix: handle Japanese artist name order in Tidal/Qobuz matching 2026-01-13 20:31:05 +07:00
zarzet 1ec190bfe7 fix: multiple bugfixes for v3.0.0-beta.2 2026-01-13 20:12:35 +07:00
zarzet 7ca032b3f5 fix: remove unnecessary PopScope to prevent back gesture freeze
Removes PopScope wrapper from settings pages that don't need it.
PopScope with canPop: true was causing race condition with Android
gesture navigation, freezing the app.
2026-01-13 18:18:41 +07:00
zarzet 00753ffe86 chore: increase log buffer size from 500 to 1000 entries 2026-01-12 23:17:30 +07:00
34 changed files with 3226 additions and 1210 deletions
+2
View File
@@ -345,6 +345,8 @@ jobs:
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
fi
# Save to file for multiline support
+6
View File
@@ -53,3 +53,9 @@ ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
# Agent instructions
AGENTS.md
+245 -11
View File
@@ -1,5 +1,244 @@
# Changelog
## [3.0.0] - 2026-01-14
### Extension System (Major Feature)
SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more.
#### Extension Store
- Browse and install extensions directly from the app
- New "Store" tab in bottom navigation
- Browse by category: Metadata, Download, Utility, Lyrics, Integration
- Search extensions by name, description, or tags
- One-tap install, update, and uninstall
- Offline cache for browsing without internet
#### Spotify Web Extension
- Available in Extension Store - install and enable in Settings > Extensions
- Metadata provider using Spotify's internal web player API
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
- Useful when official Spotify API is rate-limited or unavailable
#### Extension Capabilities
- **Custom Search Providers**
- **Custom URL Handlers**
- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3)
- **Post-Processing Hooks**: Extensions can process downloaded files
- **Quality Options**: Extensions can define custom quality settings
#### Extension APIs
- Full HTTP support: GET, POST, PUT, DELETE, PATCH
- Persistent cookie jar per extension
- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams`
- Storage API for persistent data
- File API for file operations
- HMAC-SHA1 utility for cryptographic operations
#### Security
- Sandboxed JavaScript runtime (goja)
- Permission-based access control
- Network domain whitelisting
- Improved credential encryption with per-installation random salt
### Added
- **Album Folder Structure Setting**: Option to remove artist folder from album path
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
- `Album Only`: `Albums/Album Name/`
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Toggle in Settings > Download > Separate Singles Folder
- **Parallel API Calls**: Download URL fetching now uses parallel requests
- Tidal: All 8 APIs requested simultaneously, first success wins
- Qobuz: Both APIs requested simultaneously, first success wins
- Significantly reduces download URL fetch time
### UI/UX Improvements
- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters
- Swipe left/right to switch between filter tabs
- Filter chips sync with swipe position
- Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home
- Natural gesture feel - drag connects to parent navigation
- **Improved File Open Intent**: Play button in History now correctly opens music players only
- Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files
- Prevents system from showing unrelated apps in the "Open with" dialog
### Fixed
- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space
- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error
- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
- Added `PopScope` with `canPop: true` to all settings pages
- Changed navigation to use `PageRouteBuilder` with proper slide transition
- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode
- Made dialog scrollable with max height constraint
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
- "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura"
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen
- Duplicate detection prefix now stripped before saving to history
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error
- Go backend now handles both array and object formats from extensions
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
- Now shows proper message: "Cannot write to folder, check storage permission"
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
- Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
### Changed
- **Extension Manifest**: New `file` permission required for file operations
```json
"permissions": {
"network": ["api.example.com"],
"storage": true,
"file": true
}
```
### Technical
- Go backend: Simplified parallel download result handling in Tidal/Qobuz
- Go backend: Removed unused functions and fixed bit shifting warnings
- Release workflow: Fixed duplicate `---` separator in release notes
---
## [3.0.0-beta.2] - 2026-01-13
### Added
- **Album Folder Structure Setting**: Option to remove artist folder from album path
- New setting in Download Settings when "Separate Singles Folder" is enabled
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
- `Album Only`: `Albums/Album Name/`
- Requested by user who prefers flat album organization
### Fixed
- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings
- Added `PopScope` with `canPop: true` to all settings pages
- Changed navigation to use `PageRouteBuilder` with proper slide transition
- Fixes predictive back gesture conflict on devices with gesture navigation
- Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error
- Go backend now handles both array and object formats from extensions
- Extensions returning `[{track}, {track}]` now work correctly
- Extensions returning `{tracks: [...], total: N}` still work as before
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
- Added missing `spotifySize300` constant (300x300 size code)
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
- Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path
- Go backend `cover.go` now directly replaces URL without HEAD verification
- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled
- `copyWith` in `AppSettings` couldn't set `searchProvider` to `null`
- Added `clearSearchProvider` boolean parameter to properly clear the value
- Settings menu now correctly switches back to default provider
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
- `_performSearch` now checks if extension is still enabled before calling custom search
- Automatically falls back to Deezer/Spotify search if extension was disabled
- Clears `searchProvider` setting if extension no longer available
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
- Added `mounted` check after async operation in `_initialize()`
- Prevents crash when navigating away from Store tab during initialization
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download
- Duplicate detection was adding `EXISTS:` prefix to file paths
- Prefix now stripped before saving to download history
- Legacy history items with prefix are handled gracefully
- **History Error Badge**: Fixed error badge showing on history items even when file exists
- `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence
- File open and delete operations also use cleaned path
- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions
- Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }`
- Go backend `HandleURLWithExtensionJSON` now includes albums in artist response
- Added `AlbumType` field to `ExtAlbumMetadata` struct
- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs
- Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items`
- Logs correctly show "Fetched track: {title} by {artist}"
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names with different order
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
- Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching
- Handles Japanese name order (family name first) vs Western name order (given name first)
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
- "RADWIMPS feat. Toko Miura" now matches when Qobuz/Tidal only shows "Toko Miura"
- Split artists by separators (`, `, `feat.`, `ft.`, `&`, `and`, `x`)
- Match if ANY expected artist matches ANY found artist
- **Cover Download Logging**: Improved cover download logs for debugging
- Shows original URL, upgrade steps, and final URL
- Displays estimated resolution based on file size
- Logs now appear in Settings > Logs via GoLog
---
## [3.0.0-beta.1] - 2026-01-13
### Security
@@ -22,6 +261,7 @@
### Fixed
- Extension packages now preserve directory structure (subdirectories supported)
- Back gesture freeze in settings pages on Android gesture navigation
---
@@ -30,6 +270,7 @@
### Added
- **Extension Store**: Browse and install extensions directly from the app
- New "Store" tab in bottom navigation
- Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration)
- Search extensions by name, description, or tags
@@ -38,6 +279,7 @@
- Extensions hosted at github.com/zarzet/SpotiFLAC-Extension
- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns
- Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc.
- Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }`
- Implement `handleUrl(url)` function in extension to parse and return track metadata
@@ -45,6 +287,7 @@
- Supports share intents and paste from clipboard
- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers
- Added `type: "artist"` handling in track_provider.dart
- Navigate to artist screen with albums list from extension
@@ -122,7 +365,7 @@
- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
- `http.put(url, body, headers)` - PUT requests
- `http.delete(url, headers)` - DELETE requests
- `http.delete(url, headers)` - DELETE requests
- `http.patch(url, body, headers)` - PATCH requests
- `http.clearCookies()` - Clear all cookies for the extension
- **Persistent Cookie Jar**: Each extension now has its own cookie jar
@@ -164,6 +407,7 @@
## [3.0.0-alpha.1] - 2026-01-11
#### Extension System
- **Custom Search Providers**: Extensions can now provide custom search functionality
- YouTube, SoundCloud, and other platforms via extensions
- Custom search placeholder text per extension
@@ -242,16 +486,6 @@
- **Android Changes**:
- `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods
### Documentation
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- Added thumbnail ratio customization section
- Added extension upgrade documentation
- Added settings fields table with `secret` field
- Added new troubleshooting entries
- Updated table of contents
- Updated changelog
---
## [2.2.8] - 2026-01-12
+6 -19
View File
@@ -40,30 +40,19 @@ To use Spotify as your search source without hitting rate limits:
4. Enter your Client ID and Secret
5. Change **Search Source** to Spotify
## Extensions (Alpha)
## Extensions
> **Alpha Feature**: Extensions are now available in alpha. Some features may be unstable or change in future releases.
SpotiFLAC supports extensions to add custom metadata and download providers. Extensions are written in JavaScript and run in a secure sandbox.
### Features
- **Metadata Providers**: Add new sources for track/album/artist search
- **Download Providers**: Add new sources for audio downloads
- **Custom Settings**: Extensions can have user-configurable settings
- **Provider Priority**: Set the order in which providers are tried
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
1. Download a `.spotiflac-ext` file
2. Go to **Settings > Extensions**
3. Tap **Install Extension** and select the file
1. Go to **Store** tab in the app
2. Browse and install extensions with one tap
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md) for complete documentation.
### Example Extensions
Sample extensions are available in the [docs/extensions_example](docs/extensions_example) folder:
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
## Other project
@@ -74,8 +63,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
## Disclaimer
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
+48 -26
View File
@@ -9,10 +9,20 @@ import (
// Spotify image size codes (same as PC version)
const (
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
return imageURL
}
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
@@ -20,17 +30,27 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("no cover URL provided")
}
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
GoLog("[Cover] Original URL: %s", coverURL)
// Upgrade to max quality if requested
downloadURL := coverURL
// First upgrade small (300) to medium (640) - always do this
downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
// Then upgrade to max quality if requested
if maxQuality {
downloadURL = upgradeToMaxQuality(coverURL)
if downloadURL != coverURL {
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
} else {
GoLog("[Cover] Max resolution not available, using 640x640")
}
}
GoLog("[Cover] Final URL: %s", downloadURL)
client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
@@ -54,12 +74,25 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
// Calculate approximate resolution from file size
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
resolution = "~2000x2000 (hi-res)"
} else if sizeKB > 50 {
resolution = "~640x640"
} else {
resolution = "~300x300"
}
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
return data, nil
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Uses same logic as PC version - replaces 640x640 size code with max resolution
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
@@ -67,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
// ab67616d000082c1 = Max resolution (~2000x2000)
if strings.Contains(coverURL, spotifySize640) {
// Try max resolution first
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Verify max resolution URL is available
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("HEAD", maxURL, nil)
if err == nil {
resp, err := DoRequestWithUserAgent(client, req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return maxURL
}
}
}
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
return coverURL
@@ -93,9 +112,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
return ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
return upgradeToMaxQuality(imageURL)
result = upgradeToMaxQuality(result)
}
return imageURL
return result
}
+59 -1
View File
@@ -1440,6 +1440,41 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
// ==================== EXTENSION CUSTOM SEARCH ====================
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
// Extension not found, return original track
return trackJSON, nil
}
if !ext.Manifest.IsMetadataProvider() {
// Not a metadata provider, return original
return trackJSON, nil
}
var track ExtTrackMetadata
if err := json.Unmarshal([]byte(trackJSON), &track); err != nil {
return trackJSON, fmt.Errorf("failed to parse track: %w", err)
}
provider := NewExtensionProviderWrapper(ext)
enrichedTrack, err := provider.EnrichTrack(&track)
if err != nil {
// Error enriching, return original
return trackJSON, nil
}
jsonBytes, err := json.Marshal(enrichedTrack)
if err != nil {
return trackJSON, nil
}
return string(jsonBytes), nil
}
// CustomSearchWithExtensionJSON performs custom search using an extension
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
manager := GetExtensionManager()
@@ -1597,11 +1632,34 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
// Add artist info if present
if result.Artist != nil {
response["artist"] = map[string]interface{}{
artistResponse := map[string]interface{}{
"id": result.Artist.ID,
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
}
// Add albums if present
if len(result.Artist.Albums) > 0 {
albums := make([]map[string]interface{}, len(result.Artist.Albums))
for i, album := range result.Artist.Albums {
albumType := album.AlbumType
if albumType == "" {
albumType = "album"
}
albums[i] = map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"images": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": albumType,
}
}
artistResponse["albums"] = albums
}
response["artist"] = artistResponse
}
jsonBytes, err := json.Marshal(response)
+4 -3
View File
@@ -456,9 +456,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("Extension is missing index.js file")
}
// Check if extension already loaded - skip if already exists (for directory loading on startup)
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' is already loaded", manifest.DisplayName)
// Check if extension already loaded - skip silently (for directory loading on startup)
if existing, exists := m.extensions[manifest.Name]; exists {
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
return existing, nil
}
// Create data directory for extension
+118 -1
View File
@@ -47,6 +47,7 @@ type ExtAlbumMetadata struct {
CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks"`
ProviderID string `json:"provider_id"`
}
@@ -161,8 +162,19 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
}
var searchResult ExtSearchResult
// Try to parse as ExtSearchResult object first
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
return nil, fmt.Errorf("failed to parse search result: %w", err)
// If that fails, try parsing as array of tracks directly
var tracks []ExtTrackMetadata
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
}
// Wrap array in ExtSearchResult
searchResult = ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
}
}
// Set provider ID on all tracks
@@ -314,6 +326,72 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return &artist, nil
}
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
// This is called lazily when download starts, not when playlist/album is loaded
// Extension should implement enrichTrack(track) function that returns enriched track
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return track, nil // Not a metadata provider, return as-is
}
if !p.extension.Enabled {
return track, nil // Extension disabled, return as-is
}
// Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track)
if err != nil {
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
return track, nil // Return original on error
}
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.enrichTrack === 'function') {
var track = %s;
return extension.enrichTrack(track);
}
return null;
})()
`, string(trackJSON))
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID)
} else {
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
}
return track, nil // Return original on error
}
// If extension doesn't implement enrichTrack or returns null, return original
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return track, nil
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
GoLog("[Extension] EnrichTrack: failed to marshal result: %v\n", err)
return track, nil
}
var enrichedTrack ExtTrackMetadata
if err := json.Unmarshal(jsonBytes, &enrichedTrack); err != nil {
GoLog("[Extension] EnrichTrack: failed to parse enriched track: %v\n", err)
return track, nil
}
// Preserve provider ID
enrichedTrack.ProviderID = track.ProviderID
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
return &enrichedTrack, nil
}
// ==================== Download Provider Methods ====================
// CheckAvailability checks if a track is available for download
@@ -624,6 +702,45 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
var lastErr error
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
// This is done lazily at download time, not when playlist/album is loaded
if req.Source != "" && !isBuiltInProvider(req.Source) {
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
provider := NewExtensionProviderWrapper(ext)
trackMeta := &ExtTrackMetadata{
ID: req.SpotifyID,
Name: req.TrackName,
Artists: req.ArtistName,
AlbumName: req.AlbumName,
DurationMS: req.DurationMS,
ISRC: req.ISRC,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ProviderID: req.Source,
}
enrichedTrack, err := provider.EnrichTrack(trackMeta)
if err == nil && enrichedTrack != nil {
// Update request with enriched data
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
req.ISRC = enrichedTrack.ISRC
}
// Can also update other fields if needed
if enrichedTrack.Name != "" {
req.TrackName = enrichedTrack.Name
}
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
}
}
}
// If source extension is specified, try it first before the priority list
if req.Source != "" && !isBuiltInProvider(req.Source) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
+4 -4
View File
@@ -498,7 +498,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
}
// Find udta atom inside moov, or create one
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
udtaPos := findAtom(data, "udta", moovPos+8)
// Build new metadata atoms
@@ -507,12 +507,12 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
var newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
// udta exists, find meta inside it or replace
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
metaPos := findAtom(data, "meta", udtaPos+8)
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
// Replace existing meta atom
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
newData = append(newData, data[:metaPos]...)
newData = append(newData, metaAtom...)
newData = append(newData, data[metaPos+metaSize:]...)
@@ -570,7 +570,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
// findAtom finds an atom by name starting from offset
func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; {
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
if size < 8 {
break
}
+88 -98
View File
@@ -64,24 +64,27 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
// Split expected artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
expectedArtists := qobuzSplitArtists(normExpected)
foundArtists := qobuzSplitArtists(normFound)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
// Check if ANY expected artist matches ANY found artist
for _, exp := range expectedArtists {
for _, fnd := range foundArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if qobuzSameWordsUnordered(exp, fnd) {
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
}
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
@@ -96,6 +99,67 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false
}
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
normalized = strings.ReplaceAll(normalized, " ft ", "|")
normalized = strings.ReplaceAll(normalized, " & ", "|")
normalized = strings.ReplaceAll(normalized, " and ", "|")
normalized = strings.ReplaceAll(normalized, ", ", "|")
normalized = strings.ReplaceAll(normalized, " x ", "|")
parts := strings.Split(normalized, "|")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func qobuzSameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false
}
// Sort and compare
sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA)
copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] {
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
}
if sortedB[i] > sortedB[j] {
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
}
}
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
@@ -725,12 +789,10 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
// Collect results - return first success
var errors []string
var firstSuccess *qobuzAPIResult
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil && firstSuccess == nil {
firstSuccess = &result
if result.err == nil {
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
// Drain remaining results to avoid goroutine leaks
@@ -741,91 +803,19 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
}(len(apis) - i - 1)
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
return firstSuccess.apiURL, firstSuccess.downloadURL, nil
} else if result.err != nil {
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
return result.apiURL, result.downloadURL, nil
}
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
}
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
}
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality}
func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
}
client := NewHTTPClientWithTimeout(DefaultTimeout)
retryConfig := DefaultRetryConfig()
var errors []string
for _, apiURL := range apis {
// All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality}
// The apiURL already includes the path, just append trackID and quality
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
GoLog("[Qobuz] Trying: %s\n", reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
resp, err := DoRequestWithRetry(client, req, retryConfig)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
body, err := ReadResponseBody(resp)
resp.Body.Close()
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
continue
}
// Check if response is HTML (error page)
if len(body) > 0 && body[0] == '<' {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON"))
continue
}
// Check for error in JSON response
var errorResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error))
continue
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error()))
continue
}
if result.URL != "" {
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
return apiURL, result.URL, nil
}
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response"))
}
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
+89 -117
View File
@@ -738,13 +738,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
// Collect results - return first success
var errors []string
var firstSuccess *tidalAPIResult
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil && firstSuccess == nil {
if result.err == nil {
// First success - use this one
firstSuccess = &result
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
@@ -756,109 +754,19 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
}(len(apis) - i - 1)
GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
return firstSuccess.apiURL, firstSuccess.info, nil
} else if result.err != nil {
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
return result.apiURL, result.info, nil
}
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
}
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
// Returns the first successful result (supports both v1 and v2 API formats)
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
}
client := NewHTTPClientWithTimeout(DefaultTimeout)
retryConfig := DefaultRetryConfig()
var errors []string
for _, apiURL := range apis {
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
GoLog("[Tidal] Trying API: %s\n", reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
resp, err := DoRequestWithRetry(client, req, retryConfig)
if err != nil {
GoLog("[Tidal] API error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
body, err := ReadResponseBody(resp)
resp.Body.Close()
if err != nil {
GoLog("[Tidal] Read body error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
continue
}
// Log response preview
bodyPreview := string(body)
if len(bodyPreview) > 300 {
bodyPreview = bodyPreview[:300] + "..."
}
GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
if v2Response.Data.AssetPresentation == "PREVIEW" {
GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
continue
}
GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL)
info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}
return apiURL, info, nil
}
// Fallback to v1 format (array with OriginalTrackUrl)
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
info := TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}
return apiURL, info, nil
}
}
}
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
}
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
@@ -1253,24 +1161,27 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true
}
// Check first artist (before comma or feat)
spotifyFirst := strings.Split(normSpotify, ",")[0]
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
spotifyFirst = strings.TrimSpace(spotifyFirst)
// Split artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
spotifyArtists := splitArtists(normSpotify)
tidalArtists := splitArtists(normTidal)
tidalFirst := strings.Split(normTidal, ",")[0]
tidalFirst = strings.Split(tidalFirst, " feat")[0]
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
tidalFirst = strings.TrimSpace(tidalFirst)
if spotifyFirst == tidalFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
return true
// Check if ANY expected artist matches ANY found artist
for _, exp := range spotifyArtists {
for _, fnd := range tidalArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if sameWordsUnordered(exp, fnd) {
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
}
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
@@ -1286,6 +1197,67 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return false
}
// splitArtists splits artist string by common separators
func splitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
normalized = strings.ReplaceAll(normalized, " ft ", "|")
normalized = strings.ReplaceAll(normalized, " & ", "|")
normalized = strings.ReplaceAll(normalized, " and ", "|")
normalized = strings.ReplaceAll(normalized, ", ", "|")
normalized = strings.ReplaceAll(normalized, " x ", "|")
parts := strings.Split(normalized, "|")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// sameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false
}
// Sort and compare
sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA)
copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] {
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
}
if sortedB[i] > sortedB[j] {
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
}
}
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
// titlesMatch checks if track titles are similar enough
func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
@@ -1520,7 +1492,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
// Strategy 2: Try SongLink if we have Spotify ID
if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string
+41
View File
@@ -517,6 +517,47 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Store
case "initExtensionStore":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendInitExtensionStoreJSON(cacheDir, &error)
if let error = error { throw error }
return nil
case "getStoreExtensions":
let args = call.arguments as! [String: Any]
let forceRefresh = args["force_refresh"] as? Bool ?? false
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
if let error = error { throw error }
return response
case "searchStoreExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as? String ?? ""
let category = args["category"] as? String ?? ""
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
if let error = error { throw error }
return response
case "getStoreCategories":
let response = GobackendGetStoreCategoriesJSON(&error)
if let error = error { throw error }
return response
case "downloadStoreExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let destDir = args["dest_dir"] as! String
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
if let error = error { throw error }
return response
case "clearStoreCache":
GobackendClearStoreCacheJSON(&error)
if let error = error { throw error }
return nil
default:
throw NSError(
domain: "SpotiFLAC",
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.0.0-beta.1';
static const String buildNumber = '54';
static const String version = '3.0.0';
static const String buildNumber = '57';
static const String fullVersion = '$version+$buildNumber';
+6 -1
View File
@@ -28,6 +28,7 @@ class AppSettings {
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final String albumFolderStructure; // artist_album or album_only
final bool showExtensionStore; // Show Extension Store tab in navigation
const AppSettings({
@@ -55,6 +56,7 @@ class AppSettings {
this.useExtensionProviders = true, // Default: use extensions when available
this.searchProvider, // Default: null (use Deezer/Spotify)
this.separateSingles = false, // Default: disabled
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
});
@@ -82,7 +84,9 @@ class AppSettings {
bool? enableLogging,
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
}) {
return AppSettings(
@@ -108,8 +112,9 @@ class AppSettings {
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: searchProvider ?? this.searchProvider,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
);
}
+2
View File
@@ -32,6 +32,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
);
@@ -61,5 +62,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
};
+50 -5
View File
@@ -669,7 +669,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
/// Build output directory based on folder organization setting and separateSingles
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false}) async {
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
String baseDir = state.outputDir;
// If separateSingles is enabled, use Albums/Singles structure
@@ -686,10 +686,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
return singlesPath;
} else {
// Albums go to Albums/Artist/Album structure
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
// Albums folder structure based on setting
final albumName = _sanitizeFolderName(track.albumName);
final albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
String albumPath;
if (albumFolderStructure == 'album_only') {
// Albums/Album structure (no artist folder)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
} else {
// Albums/Artist/Album structure (default)
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
}
final dir = Directory(albumPath);
if (!await dir.exists()) {
await dir.create(recursive: true);
@@ -1001,13 +1010,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Upgrade Spotify cover URL to max quality (~2000x2000)
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
// First upgrade small (300) to medium (640)
var result = coverUrl;
if (result.contains(spotifySize300)) {
result = result.replaceFirst(spotifySize300, spotifySize640);
}
// Then upgrade medium (640) to max
if (result.contains(spotifySize640)) {
result = result.replaceFirst(spotifySize640, spotifySizeMax);
}
return result;
}
/// Embed metadata and cover to a FLAC file after M4A conversion
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
final settings = ref.read(settingsProvider);
// Download cover first
String? coverPath;
final coverUrl = track.coverUrl;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
// Upgrade cover URL to max quality if setting is enabled
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
@@ -1446,6 +1484,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
);
// Use quality override if set, otherwise use default from settings
@@ -1557,6 +1596,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
// Strip EXISTS: prefix from duplicate detection
if (filePath != null && filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
}
_log.i('Download success, file: $filePath');
// Get actual quality from response (if available)
+10 -1
View File
@@ -196,7 +196,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setSearchProvider(String? provider) {
state = state.copyWith(searchProvider: provider);
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearSearchProvider: true);
} else {
state = state.copyWith(searchProvider: provider);
}
_saveSettings();
}
@@ -217,6 +221,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAlbumFolderStructure(String structure) {
state = state.copyWith(albumFolderStructure: structure);
_saveSettings();
}
void setShowExtensionStore(bool enabled) {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();
+11 -1
View File
@@ -81,6 +81,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Future<void> _performSearch(String query) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider;
// Skip if same query already searched with same provider
@@ -88,11 +89,20 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey;
if (searchProvider != null && searchProvider.isNotEmpty) {
// Check if extension search provider is set AND still enabled
final isExtensionEnabled = searchProvider != null &&
searchProvider.isNotEmpty &&
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) {
// Use custom search from extension
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
} else {
// Use default search (Deezer/Spotify)
// Also clear searchProvider if it was set but extension is disabled
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
+8 -2
View File
@@ -122,6 +122,8 @@ class _MainShellState extends ConsumerState<MainShell> {
void _onPageChanged(int index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
// Unfocus any text field when switching tabs to prevent keyboard from appearing
FocusScope.of(context).unfocus();
}
}
@@ -190,7 +192,11 @@ class _MainShellState extends ConsumerState<MainShell> {
// Build tabs and destinations based on settings
final tabs = <Widget>[
const HomeTab(),
const QueueTab(),
QueueTab(
parentPageController: _pageController,
parentPageIndex: 1,
nextPageIndex: showStore ? 2 : 3,
),
if (showStore) const StoreTab(),
const SettingsTab(),
];
@@ -254,7 +260,7 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const BouncingScrollPhysics(),
physics: const ClampingScrollPhysics(),
children: tabs,
),
bottomNavigationBar: NavigationBar(
+943 -422
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -13,45 +13,45 @@ class AboutPage extends StatelessWidget {
final topPadding = MediaQuery.of(context).padding.top;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
// When collapsed (expandRatio=0): left=56 to avoid back button
// When expanded (expandRatio=1): left=24 for normal padding
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'About',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
// When collapsed (expandRatio=0): left=56 to avoid back button
// When expanded (expandRatio=1): left=24 for normal padding
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'About',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// App header card with logo and description
SliverToBoxAdapter(
@@ -220,7 +220,7 @@ class AboutPage extends StatelessWidget {
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
),
);
}
@@ -15,27 +15,27 @@ class AppearanceSettingsPage extends ConsumerWidget {
final topPadding = MediaQuery.of(context).padding.top;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
topPadding: topPadding,
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
topPadding: topPadding,
),
),
// Preview Section
SliverToBoxAdapter(
+151 -99
View File
@@ -23,49 +23,49 @@ class DownloadSettingsPage extends ConsumerWidget {
final isBuiltInService = _builtInServices.contains(settings.defaultService);
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Download',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Download',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Service section
const SliverToBoxAdapter(
@@ -196,6 +196,19 @@ class DownloadSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setSeparateSingles(value),
),
if (settings.separateSingles)
SettingsItem(
icon: Icons.folder_outlined,
title: 'Album Folder Structure',
subtitle: settings.albumFolderStructure == 'album_only'
? 'Albums/Album Name/'
: 'Albums/Artist/Album Name/',
onTap: () => _showAlbumFolderStructurePicker(
context,
ref,
settings.albumFolderStructure,
),
),
if (!settings.separateSingles)
SettingsItem(
icon: Icons.create_new_folder_outlined,
@@ -221,6 +234,39 @@ class DownloadSettingsPage extends ConsumerWidget {
);
}
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Artist / Album'),
subtitle: const Text('Albums/Artist Name/Album Name/'),
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: const Text('Album Only'),
subtitle: const Text('Albums/Album Name/'),
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
Navigator.pop(context);
},
),
],
),
),
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
@@ -527,74 +573,80 @@ class DownloadSettingsPage extends ConsumerWidget {
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Organize downloaded files into folders',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
),
_FolderOption(
title: 'None',
subtitle: 'All files in download folder',
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('none');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist',
subtitle: 'Separate folder for each artist',
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Album',
subtitle: 'Separate folder for each album',
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('album');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album',
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Organize downloaded files into folders',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_FolderOption(
title: 'None',
subtitle: 'All files in download folder',
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('none');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist',
subtitle: 'Separate folder for each artist',
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Album',
subtitle: 'Separate folder for each album',
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('album');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album',
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
),
);
+163 -65
View File
@@ -56,11 +56,13 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final topPadding = MediaQuery.of(context).padding.top;
final hasError = extension.status == 'error';
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@@ -186,6 +188,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
const SizedBox(height: 16),
_InfoRow(label: 'Author', value: extension.author),
_InfoRow(label: 'ID', value: extension.id),
_InfoRow(label: 'Version', value: 'v${extension.version}'),
if (hasError && extension.errorMessage != null)
_InfoRow(
label: 'Error',
@@ -236,28 +239,57 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
? '${extension.postProcessing!.hooks.length} hook(s) available'
: null,
),
_CapabilityItem(
icon: Icons.link,
title: 'URL Handler',
enabled: extension.hasURLHandler,
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
? '${extension.urlHandler!.patterns.length} pattern(s)'
: null,
showDivider: false,
),
],
),
),
// Search Provider Section (if extension has custom search)
if (extension.hasCustomSearch) ...[
// URL Handler Section (if extension handles URLs)
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Search Provider'),
child: SettingsSectionHeader(title: 'URL Handler'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_SearchProviderInfo(
extension: extension,
_URLHandlerInfo(
patterns: extension.urlHandler!.patterns,
),
],
),
),
],
// Quality Options Section (for download providers)
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Quality Options'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.qualityOptions.asMap().entries.map((entry) {
final index = entry.key;
final quality = entry.value;
return _QualityOptionItem(
quality: quality,
showDivider: index < extension.qualityOptions.length - 1,
);
}).toList(),
),
),
],
// Post-Processing Hooks (if available)
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
const SliverToBoxAdapter(
@@ -348,6 +380,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -817,17 +850,18 @@ class _PostProcessingHookItem extends StatelessWidget {
}
}
class _SearchProviderInfo extends StatelessWidget {
final Extension extension;
const _SearchProviderInfo({
required this.extension,
class _URLHandlerInfo extends StatelessWidget {
final List<String> patterns;
const _URLHandlerInfo({
required this.patterns,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final searchBehavior = extension.searchBehavior;
return Padding(
padding: const EdgeInsets.all(16),
@@ -840,12 +874,12 @@ class _SearchProviderInfo extends StatelessWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.manage_search,
color: colorScheme.onSecondaryContainer,
Icons.link,
color: colorScheme.onTertiaryContainer,
size: 24,
),
),
@@ -855,14 +889,14 @@ class _SearchProviderInfo extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Custom Search Available',
'Custom URL Handling',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'This extension provides its own search functionality',
'This extension can handle links from these sites',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -873,25 +907,38 @@ class _SearchProviderInfo extends StatelessWidget {
],
),
const SizedBox(height: 16),
// Search placeholder info
if (searchBehavior?.placeholder != null) ...[
_InfoTile(
icon: Icons.text_fields,
label: 'Search Hint',
value: searchBehavior!.placeholder!,
),
const SizedBox(height: 8),
],
// Primary search info
_InfoTile(
icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border,
label: 'Priority',
value: searchBehavior?.primary == true
? 'Primary search provider'
: 'Secondary search provider',
Wrap(
spacing: 8,
runSpacing: 8,
children: patterns.map((pattern) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.language,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
pattern,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
);
}).toList(),
),
const SizedBox(height: 16),
// Usage instructions
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -908,7 +955,7 @@ class _SearchProviderInfo extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.',
'Share links from these sites to SpotiFLAC and this extension will handle them.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -923,44 +970,95 @@ class _SearchProviderInfo extends StatelessWidget {
}
}
class _InfoTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
class _QualityOptionItem extends StatelessWidget {
final QualityOption quality;
final bool showDivider;
const _InfoTile({
required this.icon,
required this.label,
required this.value,
const _QualityOptionItem({
required this.quality,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'$label: ',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.high_quality,
color: colorScheme.onSecondaryContainer,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quality.label,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (quality.description != null && quality.description!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
quality.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 4),
Text(
quality.id,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.primary,
fontFamily: 'monospace',
),
),
],
),
),
if (quality.settings.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${quality.settings.length} setting${quality.settings.length > 1 ? 's' : ''}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 72,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
),
],
);
}
+6 -3
View File
@@ -45,9 +45,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
@@ -248,6 +250,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
],
),
),
);
}
+45 -45
View File
@@ -125,59 +125,59 @@ class _LogScreenState extends State<LogScreen> {
final logs = _filteredLogs;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
// Collapsing App Bar with back button - same as other settings pages
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
actions: [
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'share',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Share logs'),
contentPadding: EdgeInsets.zero,
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'share',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Share logs'),
contentPadding: EdgeInsets.zero,
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.delete_outline),
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Clear logs'),
contentPadding: EdgeInsets.zero,
),
+37 -41
View File
@@ -18,49 +18,49 @@ class OptionsSettingsPage extends ConsumerWidget {
final topPadding = MediaQuery.of(context).padding.top;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Options',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Options',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Search Source section
const SliverToBoxAdapter(
@@ -845,8 +845,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.graphic_eq,
label: 'Deezer',
badge: 'Free',
badgeColor: colorScheme.tertiary,
// Not selected if extension is active
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
onTap: () {
@@ -861,8 +859,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.music_note,
label: 'Spotify',
badge: 'API Key',
badgeColor: colorScheme.secondary,
// Not selected if extension is active
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
onTap: () {
+22 -1
View File
@@ -116,6 +116,27 @@ class SettingsTab extends ConsumerWidget {
}
void _navigateTo(BuildContext context, Widget page) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
Navigator.of(context).push(
// Use PageRouteBuilder for better predictive back gesture support
// MaterialPageRoute can cause freeze on some devices with gesture navigation
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Use slide transition similar to MaterialPageRoute
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
),
);
}
}
@@ -0,0 +1,751 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class ExtensionDetailsScreen extends ConsumerStatefulWidget {
final StoreExtension extension;
const ExtensionDetailsScreen({super.key, required this.extension});
@override
ConsumerState<ExtensionDetailsScreen> createState() =>
_ExtensionDetailsScreenState();
}
class _ExtensionDetailsScreenState
extends ConsumerState<ExtensionDetailsScreen> {
@override
Widget build(BuildContext context) {
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
final storeState = ref.watch(storeProvider);
// Find our extension in the store state to get the latest status
// If not found in current store state (rare), fallback to widget.extension
final liveExtension =
storeState.extensions
.where((e) => e.id == widget.extension.id)
.firstOrNull ??
widget.extension;
final isDownloading = storeState.downloadingId == liveExtension.id;
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(context, liveExtension, colorScheme),
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
_buildSectionHeader(
context,
'About',
Icons.info_outline,
colorScheme,
),
_buildDescription(context, liveExtension, colorScheme),
if (liveExtension.tags.isNotEmpty) ...[
_buildSectionHeader(context, 'Tags', Icons.tag, colorScheme),
_buildTags(context, liveExtension, colorScheme),
],
_buildSectionHeader(
context,
'Information',
Icons.table_chart_outlined,
colorScheme,
),
_buildMetadataTable(context, liveExtension, colorScheme),
_buildSectionHeader(
context,
'Capabilities',
Icons.extension_outlined,
colorScheme,
),
_buildCapabilities(context, liveExtension, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(
BuildContext context,
StoreExtension ext,
ColorScheme colorScheme,
) {
return SliverAppBar(
expandedHeight: 200,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Center(
child: Padding(
padding: const EdgeInsets.only(top: kToolbarHeight),
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: colorScheme.surfaceContainerHighest,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: ext.iconUrl != null && ext.iconUrl!.isNotEmpty
? Image.network(
ext.iconUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_buildFallbackIcon(ext, colorScheme, 50),
)
: _buildFallbackIcon(ext, colorScheme, 50),
),
),
),
),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildFallbackIcon(
StoreExtension ext,
ColorScheme colorScheme,
double size,
) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getCategoryIcon(ext.category),
size: size,
color: colorScheme.onSurfaceVariant,
),
);
}
Widget _buildInfoCard(
BuildContext context,
StoreExtension ext,
ColorScheme colorScheme,
bool isDownloading,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ext.displayName,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'by ${ext.author}',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
],
),
const SizedBox(height: 16),
// Badges row
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_Badge(
label: 'v${ext.version}',
color: colorScheme.secondaryContainer,
textColor: colorScheme.onSecondaryContainer,
),
_Badge(
label: _getCategoryName(ext.category),
color: colorScheme.tertiaryContainer,
textColor: colorScheme.onTertiaryContainer,
),
if (ext.isInstalled)
_Badge(
label: 'Installed',
color: colorScheme.primaryContainer,
textColor: colorScheme.onPrimaryContainer,
icon: Icons.check,
),
],
),
const SizedBox(height: 24),
// Action Buttons
if (isDownloading)
Center(
child: CircularProgressIndicator(
color: colorScheme.primary,
),
)
else ...[
if (ext.hasUpdate)
FilledButton.icon(
onPressed: () => _updateExtension(ext),
icon: const Icon(Icons.update),
label: Text('Update to v${ext.version}'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
)
else if (ext.isInstalled)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: null,
icon: const Icon(Icons.check),
label: const Text('Installed'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
const SizedBox(width: 12),
IconButton.filled(
onPressed: () => _uninstallExtension(ext),
icon: const Icon(Icons.delete_outline),
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer,
foregroundColor: colorScheme.onErrorContainer,
minimumSize: const Size(52, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
tooltip: 'Uninstall',
),
],
)
else
FilledButton.icon(
onPressed: () => _installExtension(ext),
icon: const Icon(Icons.download),
label: const Text('Install Extension'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
],
],
),
),
),
),
);
}
Widget _buildSectionHeader(
BuildContext context,
String title,
IconData icon,
ColorScheme colorScheme,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
);
}
Widget _buildDescription(
BuildContext context,
StoreExtension ext,
ColorScheme colorScheme,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(
ext.description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.5,
color: colorScheme.onSurface,
),
),
),
);
}
Widget _buildTags(
BuildContext context,
StoreExtension ext,
ColorScheme colorScheme,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: ext.tags
.map(
(tag) => Chip(
label: Text(tag),
backgroundColor: colorScheme.surfaceContainer,
labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
side: BorderSide.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
)
.toList(),
),
),
);
}
Widget _buildMetadataTable(
BuildContext context,
StoreExtension ext,
ColorScheme colorScheme,
) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
sliver: SliverToBoxAdapter(
child: Card(
elevation: 0,
color: colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
_MetadataRow(
label: 'Updated',
value: ext.updatedAt.isNotEmpty
? _formatDate(ext.updatedAt)
: '-',
colorScheme: colorScheme,
),
_MetadataRow(
label: 'ID',
value: ext.id,
colorScheme: colorScheme,
),
_MetadataRow(
label: 'Min App Version',
value: ext.minAppVersion ?? 'Any',
colorScheme: colorScheme,
isLast: true,
),
],
),
),
),
);
}
Widget _buildCapabilities(
BuildContext context,
StoreExtension ext,
ColorScheme colorScheme,
) {
// Determine capabilities based on category
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
final isDownloadProvider = ext.category == 'download';
final isLyricsProvider = ext.category == 'lyrics';
final isUtility = ext.category == 'utility';
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
sliver: SliverToBoxAdapter(
child: Card(
elevation: 0,
color: colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
_CapabilityRow(
icon: Icons.search,
label: 'Metadata Provider',
enabled: isMetadataProvider,
colorScheme: colorScheme,
),
_CapabilityRow(
icon: Icons.download,
label: 'Download Provider',
enabled: isDownloadProvider,
colorScheme: colorScheme,
),
_CapabilityRow(
icon: Icons.lyrics,
label: 'Lyrics Provider',
enabled: isLyricsProvider,
colorScheme: colorScheme,
),
_CapabilityRow(
icon: Icons.build,
label: 'Utility Functions',
enabled: isUtility,
colorScheme: colorScheme,
isLast: true,
),
],
),
),
),
);
}
String _formatDate(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
return 'Today';
} else if (diff.inDays == 1) {
return 'Yesterday';
} else if (diff.inDays < 7) {
return '${diff.inDays} days ago';
} else if (diff.inDays < 30) {
return '${(diff.inDays / 7).floor()} weeks ago';
} else if (diff.inDays < 365) {
return '${(diff.inDays / 30).floor()} months ago';
} else {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
} catch (_) {
return dateStr.split('T').first;
}
}
IconData _getCategoryIcon(String category) {
switch (category) {
case 'metadata':
return Icons.label_outline;
case 'download':
return Icons.download_outlined;
case 'utility':
return Icons.build_outlined;
case 'lyrics':
return Icons.lyrics_outlined;
case 'integration':
return Icons.link;
default:
return Icons.extension;
}
}
String _getCategoryName(String category) {
switch (category) {
case 'metadata':
return 'Metadata';
case 'download':
return 'Download';
case 'utility':
return 'Utility';
case 'lyrics':
return 'Lyrics';
case 'integration':
return 'Integration';
default:
return category;
}
}
Future<void> _installExtension(StoreExtension ext) async {
final tempDir = await getTemporaryDirectory();
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final success = await ref
.read(storeProvider.notifier)
.installExtension(ext.id, tempDir.path, extensionsDir);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? '${ext.displayName} installed.'
: 'Failed to install ${ext.displayName}',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _updateExtension(StoreExtension ext) async {
final tempDir = await getTemporaryDirectory();
final success = await ref
.read(storeProvider.notifier)
.updateExtension(ext.id, tempDir.path);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? '${ext.displayName} updated.'
: 'Failed to update ${ext.displayName}',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _uninstallExtension(StoreExtension ext) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Uninstall Extension?'),
content: Text('Are you sure you want to remove ${ext.displayName}?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Uninstall',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
);
if (confirm == true) {
await ref.read(extensionProvider.notifier).removeExtension(ext.id);
await ref.read(storeProvider.notifier).refresh();
if (mounted) {
Navigator.pop(context);
}
}
}
}
class _Badge extends StatelessWidget {
final String label;
final Color color;
final Color textColor;
final IconData? icon;
const _Badge({
required this.label,
required this.color,
required this.textColor,
this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: textColor),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
);
}
}
class _MetadataRow extends StatelessWidget {
final String label;
final String value;
final ColorScheme colorScheme;
final bool isLast;
const _MetadataRow({
required this.label,
required this.value,
required this.colorScheme,
this.isLast = false,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
Expanded(
child: Text(
value,
textAlign: TextAlign.end,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (!isLast)
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
indent: 16,
endIndent: 16,
),
],
);
}
}
class _CapabilityRow extends StatelessWidget {
final IconData icon;
final String label;
final bool enabled;
final ColorScheme colorScheme;
final bool isLast;
const _CapabilityRow({
required this.icon,
required this.label,
required this.enabled,
required this.colorScheme,
this.isLast = false,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
icon,
size: 20,
color: enabled ? colorScheme.primary : colorScheme.outline,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
),
),
),
Icon(
enabled ? Icons.check_circle : Icons.cancel_outlined,
size: 20,
color: enabled ? colorScheme.primary : colorScheme.outline,
),
],
),
),
if (!isLast)
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
indent: 16,
endIndent: 16,
),
],
);
}
}
+234 -179
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key});
@@ -26,6 +27,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_isInitialized = true;
final cacheDir = await getApplicationCacheDirectory();
// Check if widget is still mounted after async operation
if (!mounted) return;
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
}
@@ -43,7 +48,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
return Scaffold(
body: RefreshIndicator(
onRefresh: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true),
onRefresh: () =>
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
child: CustomScrollView(
slivers: [
// App Bar - consistent with other tabs
@@ -59,9 +65,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
@@ -93,7 +100,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref.read(storeProvider.notifier).setSearchQuery('');
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
@@ -103,9 +112,15 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
@@ -119,49 +134,68 @@ class _StoreTabState extends ConsumerState<StoreTab> {
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
_CategoryChip(
label: 'All',
icon: Icons.apps,
isSelected: state.selectedCategory == null,
onTap: () => ref.read(storeProvider.notifier).setCategory(null),
onTap: () =>
ref.read(storeProvider.notifier).setCategory(null),
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Metadata',
icon: Icons.label_outline,
isSelected: state.selectedCategory == StoreCategory.metadata,
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.metadata),
isSelected:
state.selectedCategory == StoreCategory.metadata,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.metadata),
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Download',
icon: Icons.download_outlined,
isSelected: state.selectedCategory == StoreCategory.download,
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.download),
isSelected:
state.selectedCategory == StoreCategory.download,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.download),
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Utility',
icon: Icons.build_outlined,
isSelected: state.selectedCategory == StoreCategory.utility,
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.utility),
isSelected:
state.selectedCategory == StoreCategory.utility,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.utility),
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Lyrics',
icon: Icons.lyrics_outlined,
isSelected: state.selectedCategory == StoreCategory.lyrics,
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.lyrics),
isSelected:
state.selectedCategory == StoreCategory.lyrics,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.lyrics),
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Integration',
icon: Icons.link,
isSelected: state.selectedCategory == StoreCategory.integration,
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.integration),
isSelected:
state.selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
),
],
),
@@ -178,9 +212,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
child: _buildErrorState(state.error!, colorScheme),
)
else if (state.filteredExtensions.isEmpty)
SliverFillRemaining(
child: _buildEmptyState(state, colorScheme),
)
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
else ...[
// Extensions count
SliverToBoxAdapter(
@@ -200,15 +232,19 @@ class _StoreTabState extends ConsumerState<StoreTab> {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SettingsGroup(
children: state.filteredExtensions.asMap().entries.map((entry) {
children: state.filteredExtensions.asMap().entries.map((
entry,
) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < state.filteredExtensions.length - 1,
showDivider:
index < state.filteredExtensions.length - 1,
isDownloading: state.downloadingId == ext.id,
onInstall: () => _installExtension(ext),
onUpdate: () => _updateExtension(ext),
onTap: () => _showExtensionDetails(ext),
);
}).toList(),
),
@@ -247,7 +283,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true),
onPressed: () =>
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
@@ -258,7 +295,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
}
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
final hasFilters = state.searchQuery.isNotEmpty || state.selectedCategory != null;
final hasFilters =
state.searchQuery.isNotEmpty || state.selectedCategory != null;
return Center(
child: Column(
@@ -291,23 +329,31 @@ class _StoreTabState extends ConsumerState<StoreTab> {
);
}
void _showExtensionDetails(StoreExtension ext) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ExtensionDetailsScreen(extension: ext),
),
);
}
Future<void> _installExtension(StoreExtension ext) async {
final tempDir = await getTemporaryDirectory();
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final success = await ref.read(storeProvider.notifier).installExtension(
ext.id,
tempDir.path,
extensionsDir,
);
final success = await ref
.read(storeProvider.notifier)
.installExtension(ext.id, tempDir.path, extensionsDir);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? '${ext.displayName} installed. Enable it in Settings > Extensions'
: 'Failed to install ${ext.displayName}'),
content: Text(
success
? '${ext.displayName} installed. Enable it in Settings > Extensions'
: 'Failed to install ${ext.displayName}',
),
behavior: SnackBarBehavior.floating,
),
);
@@ -317,17 +363,18 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Future<void> _updateExtension(StoreExtension ext) async {
final tempDir = await getTemporaryDirectory();
final success = await ref.read(storeProvider.notifier).updateExtension(
ext.id,
tempDir.path,
);
final success = await ref
.read(storeProvider.notifier)
.updateExtension(ext.id, tempDir.path);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? '${ext.displayName} updated to v${ext.version}'
: 'Failed to update ${ext.displayName}'),
content: Text(
success
? '${ext.displayName} updated to v${ext.version}'
: 'Failed to update ${ext.displayName}',
),
behavior: SnackBarBehavior.floating,
),
);
@@ -335,7 +382,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
}
}
class _CategoryChip extends StatelessWidget {
final String label;
final IconData icon;
@@ -354,11 +400,7 @@ class _CategoryChip extends StatelessWidget {
return FilterChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16),
const SizedBox(width: 6),
Text(label),
],
children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)],
),
selected: isSelected,
onSelected: (_) => onTap(),
@@ -373,6 +415,7 @@ class _ExtensionItem extends StatelessWidget {
final bool isDownloading;
final VoidCallback onInstall;
final VoidCallback onUpdate;
final VoidCallback? onTap;
const _ExtensionItem({
required this.extension,
@@ -380,6 +423,7 @@ class _ExtensionItem extends StatelessWidget {
required this.isDownloading,
required this.onInstall,
required this.onUpdate,
this.onTap,
});
IconData _getCategoryIcon(String category) {
@@ -406,151 +450,162 @@ class _ExtensionItem extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon - custom or category-based
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: extension.isInstalled
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: extension.iconUrl != null && extension.iconUrl!.isNotEmpty
? Image.network(
extension.iconUrl!,
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon - custom or category-based
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: extension.isInstalled
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child:
extension.iconUrl != null && extension.iconUrl!.isNotEmpty
? Image.network(
extension.iconUrl!,
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
_getCategoryIcon(extension.category),
color: extension.isInstalled
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
value:
loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
)
: Icon(
_getCategoryIcon(extension.category),
color: extension.isInstalled
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
extension.displayName,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w500),
),
);
},
)
: Icon(
_getCategoryIcon(extension.category),
color: extension.isInstalled
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
// Version badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'v${extension.version}',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
extension.displayName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
'by ${extension.author}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
// Version badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'v${extension.version}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 4),
Text(
extension.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
// Action button
if (isDownloading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (extension.hasUpdate)
FilledButton.tonal(
onPressed: onUpdate,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: const Text('Update'),
)
else if (extension.isInstalled)
OutlinedButton(
onPressed: null,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 16, color: colorScheme.outline),
const SizedBox(width: 4),
Text(
'Installed',
style: TextStyle(color: colorScheme.outline),
),
],
),
const SizedBox(height: 2),
Text(
'by ${extension.author}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else
FilledButton(
onPressed: onInstall,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
const SizedBox(height: 4),
Text(
extension.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
// Action button
if (isDownloading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (extension.hasUpdate)
FilledButton.tonal(
onPressed: onUpdate,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
child: const Text('Install'),
),
child: const Text('Update'),
)
else if (extension.isInstalled)
OutlinedButton(
onPressed: null,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 16, color: colorScheme.outline),
const SizedBox(width: 4),
Text('Installed', style: TextStyle(color: colorScheme.outline)),
],
),
)
else
FilledButton(
onPressed: onInstall,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: const Text('Install'),
),
],
],
),
),
),
if (showDivider)
+22 -10
View File
@@ -34,7 +34,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _checkFile() async {
final file = File(widget.item.filePath);
// Strip EXISTS: prefix from legacy history items
var filePath = widget.item.filePath;
if (filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7);
}
final file = File(filePath);
final exists = await file.exists();
int? size;
@@ -67,6 +73,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
// Clean filePath - strip EXISTS: prefix from legacy history items
String get cleanFilePath {
final path = item.filePath;
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
int? get bitDepth => item.bitDepth;
int? get sampleRate => item.sampleRate;
@@ -515,7 +527,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
final fileName = item.filePath.split(Platform.pathSeparator).last;
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
return Card(
@@ -631,7 +643,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// File path
InkWell(
onTap: () => _copyToClipboard(context, item.filePath),
onTap: () => _copyToClipboard(context, cleanFilePath),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
@@ -643,7 +655,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
children: [
Expanded(
child: Text(
item.filePath,
cleanFilePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
@@ -776,7 +788,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
item.spotifyId ?? '',
item.trackName,
item.artistName,
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
).timeout(
const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout
@@ -833,7 +845,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: fileExists ? () => _openFile(context, item.filePath) : null,
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
style: FilledButton.styleFrom(
@@ -890,7 +902,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
title: const Text('Copy file path'),
onTap: () {
Navigator.pop(context);
_copyToClipboard(context, item.filePath);
_copyToClipboard(context, cleanFilePath);
},
),
ListTile(
@@ -933,7 +945,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
onPressed: () async {
// Delete the file first
try {
final file = File(item.filePath);
final file = File(cleanFilePath);
if (await file.exists()) {
await file.delete();
}
@@ -984,7 +996,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _shareFile(BuildContext context) async {
final file = File(item.filePath);
final file = File(cleanFilePath);
if (!await file.exists()) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -996,7 +1008,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
await SharePlus.instance.share(
ShareParams(
files: [XFile(item.filePath)],
files: [XFile(cleanFilePath)],
text: '${item.trackName} - ${item.artistName}',
),
);
+2 -2
View File
@@ -72,7 +72,7 @@ class SettingsItem extends StatelessWidget {
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
@@ -159,7 +159,7 @@ class SettingsSwitchItem extends StatelessWidget {
child: InkWell(
onTap: isDisabled ? null : () => onChanged!(!value),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0-beta.1+54
version: 3.0.0+57
environment:
sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0-beta.1+54
version: 3.0.0+57
environment:
sdk: ^3.10.0