Compare commits

...

21 Commits

Author SHA1 Message Date
zarzet d98960d053 fix: permission error message and Android 13+ storage permission
- Fixed download showing 'Song not found' when actually permission error
- Added permission error type detection in Go backend
- Android 13+ now requests both MANAGE_EXTERNAL_STORAGE and READ_MEDIA_AUDIO
- MANAGE_EXTERNAL_STORAGE opens Settings (system-level)
- READ_MEDIA_AUDIO shows dialog (app-level, resets on clear data)
- Proper permission check before showing 'granted' status
2026-01-12 19:56:12 +07:00
zarzet d417743654 chore: bump version to 2.2.9 2026-01-12 18:47:53 +07:00
zarzet efbf5d4c5b fix: prevent duplicate entries in download history
- Add duplicate detection in addToHistory() by spotifyId, deezerId, or ISRC
- Replace existing entry and move to top when re-downloading same track
- Add _deduplicateHistory() to clean up existing duplicates on app load
- Auto-save after removing duplicates from storage

Fixes duplicate history entries when downloading same track multiple times
2026-01-12 18:25:38 +07:00
zarzet c673581c32 feat: multi-select batch delete and album grouping in history
- Add multi-select mode with long-press to select tracks
- Add bottom action bar for selection (Material 3 style)
- Add filter tabs: All/Albums/Singles
- Add album grouping view when Albums filter selected
- Add DownloadedAlbumScreen for viewing tracks in an album
- Reactive UI updates when tracks deleted
- Auto-pop when album has <2 tracks
- Update issue templates with (Stable Version) text
- Bump version to 2.2.8
2026-01-12 06:18:32 +07:00
zarzet a6d488696b chore: add extension API feature request template and ignore docs folder 2026-01-12 01:22:23 +07:00
zarzet 24ef66be4c Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-01-11 06:41:54 +07:00
zarzet d07a49f605 UI modernization: Global theme update, redesigned Options/Download settings, and smart filename editor 2026-01-11 06:41:34 +07:00
zarzet 4eba28db7a v2.2.7: CSV import metadata enrichment with Deezer fallback 2026-01-11 06:09:48 +07:00
zarzet b73a3f8912 Add CSV import and optimize Appearance settings 2026-01-11 05:56:30 +07:00
zarzet 9f47f2ce85 UI Modernization: Unified app bars, updated logos, improved settings & Deezer support 2026-01-11 04:28:41 +07:00
zarzet f2aca734a3 fix: improve logging for release builds and UI improvements
- Fix Flutter logs not appearing in release mode by bypassing Logger package
- Add detailed logging for Deezer search API calls
- Replace music_note icon with app logo on home screen
- Remove shadow/border from logo in About and Home screens
- Align icon size (40x40) with avatar in About page for consistent layout
2026-01-11 02:27:26 +07:00
Zarz Eleutherius 09cb637a86 Update VirusTotal link in README.md 2026-01-10 19:27:48 +07:00
zarzet 11e7034cec v2.2.5: In-app logging, ISP blocking detection, Latin script fix 2026-01-10 19:03:39 +07:00
zarzet f12c18d76b Add issue templates, update workflow SDK, fix about logo 2026-01-10 16:53:41 +07:00
zarzet 0da39a1b8b chore: update NDK to r27d LTS (27.3.13750724) for 16KB page size support 2026-01-10 16:48:52 +07:00
Zarz Eleutherius f29fe5054c Updated VirusTotal badge link in README
Updated VirusTotal badge link to the latest report.
2026-01-10 16:09:39 +07:00
zarzet c8c0164964 chore: update targetSdk to 36 (Android 16) with timeout handler 2026-01-10 04:51:15 +07:00
zarzet 52dd657913 fix: improve search result parsing robustness for edge cases 2026-01-10 04:48:32 +07:00
zarzet c30f9fe412 fix(ios): add setSpotifyCredentials method to AppDelegate 2026-01-10 04:36:28 +07:00
zarzet bea5dd1d4a v2.2.0: Default to Tidal, faster ISRC matching, ISRC enrichment for search 2026-01-10 04:33:05 +07:00
Zarz Eleutherius 8726a0858a Update VirusTotal link in README.md 2026-01-09 19:03:36 +07:00
50 changed files with 8285 additions and 1979 deletions
+123
View File
@@ -0,0 +1,123 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have searched existing issues and this bug hasn't been reported yet
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what you expected...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
validations:
required: true
- type: input
id: version
attributes:
label: App Version
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
placeholder: "e.g., v2.2.0"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Which platform are you using?
options:
- Android
- iOS
validations:
required: true
- type: input
id: device
attributes:
label: Device & OS Version
description: What device and OS version are you using?
placeholder: "e.g., Samsung Galaxy S24, Android 14"
validations:
required: true
- type: dropdown
id: download-service
attributes:
label: Download Service
description: Which download service were you using when the bug occurred?
options:
- Tidal
- Qobuz
- Amazon Music
- Deezer (search only)
- Not applicable
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: |
If applicable, add logs or screenshots to help explain your problem.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Reproduce the bug
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Paste logs or drag & drop screenshots here...
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context about the problem
placeholder: Add any other context...
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: README
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
about: Check the README for setup instructions and FAQ
- name: Extension Development Guide
url: https://zarz.moe/docs
about: Documentation for building SpotiFLAC extensions
+111
View File
@@ -0,0 +1,111 @@
name: Download Issue
description: Report issues with downloading specific tracks or albums
title: "[Download]: "
labels: ["download-issue"]
body:
- type: markdown
attributes:
value: |
Having trouble downloading a specific track or album? Please provide details below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
required: true
- type: dropdown
id: issue-type
attributes:
label: Issue Type
description: What kind of download issue are you experiencing?
options:
- Track not found on service
- Wrong track downloaded
- Download fails/errors
- Metadata incorrect
- Audio quality issue
- Other
validations:
required: true
- type: input
id: spotify-url
attributes:
label: Spotify URL
description: The Spotify URL of the track/album you're trying to download
placeholder: "https://open.spotify.com/track/..."
validations:
required: true
- type: input
id: track-info
attributes:
label: Track Info
description: Artist name and track title
placeholder: "Artist - Track Title"
validations:
required: true
- type: dropdown
id: download-service
attributes:
label: Download Service
description: Which service did you try to download from?
options:
- Tidal
- Qobuz
- Amazon Music
- All services
validations:
required: true
- type: dropdown
id: search-service
attributes:
label: Search Service
description: Which search service are you using?
options:
- Spotify
- Deezer
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe the issue in detail
placeholder: |
What happened? What did you expect?
If wrong track was downloaded, what track was downloaded instead?
validations:
required: true
- type: input
id: version
attributes:
label: App Version
description: Which version of SpotiFLAC are you using?
placeholder: "e.g., v2.2.0"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots / Logs
description: |
If applicable, add screenshots or logs.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Try downloading the track again
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Drag & drop screenshots or paste logs here...
@@ -0,0 +1,117 @@
name: Extension API Feature Request (Alpha)
description: Request new API features or capabilities for extension development (Extension system is in alpha)
title: "[Extension API]: "
labels: ["enhancement", "extension-api"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the SpotiFLAC Extension API!
This form is for extension developers who need new features or capabilities that don't exist yet.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have read the [Extension Development Guide](https://zarz.moe/docs)
required: true
- label: I have searched existing issues and this API feature hasn't been requested yet
required: true
- type: textarea
id: extension_goal
attributes:
label: What are you trying to build?
description: Describe the extension or feature you're developing
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
validations:
required: true
- type: textarea
id: current_limitation
attributes:
label: Current API Limitation
description: What's missing or limiting in the current extension API?
placeholder: |
The current API doesn't support:
- [missing feature 1]
- [missing feature 2]
This prevents me from...
validations:
required: true
- type: textarea
id: proposed_api
attributes:
label: Proposed API / Feature
description: Describe the API or feature you'd like to see added
placeholder: |
I would like to have:
- A new function `api.newFeature()` that does X
- A new manifest field `newOption` that enables Y
- Access to Z capability...
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use Case Example
description: Provide a code example of how you would use this feature
placeholder: |
```javascript
// Example usage in extension code
function download(request, progressCallback) {
const result = api.proposedFeature(params);
// ...
}
```
validations:
required: false
- type: dropdown
id: api_category
attributes:
label: API Category
description: What category does this feature fall under?
options:
- HTTP/Network API
- File System API
- Storage API
- FFmpeg/Audio Processing
- Manifest Options
- Runtime Functions
- UI Integration
- Authentication
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How critical is this for your extension?
options:
- Blocker - Cannot build my extension without this
- High - Major functionality depends on this
- Medium - Would significantly improve my extension
- Low - Nice to have
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Current Workaround
description: Are you using any workaround currently? If so, describe it.
placeholder: "Currently I'm working around this by..."
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, links to similar APIs, or examples from other platforms
placeholder: "Similar feature in other platforms: ..."
@@ -0,0 +1,66 @@
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please fill out the form below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have searched existing issues and this feature hasn't been requested yet
required: true
- type: textarea
id: problem
attributes:
label: Problem / Motivation
description: Is your feature request related to a problem? Please describe.
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like
placeholder: A clear description of what you want to happen...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or features you've considered
placeholder: Other approaches you've thought about...
- type: dropdown
id: category
attributes:
label: Category
description: What category does this feature fall under?
options:
- UI/UX Improvement
- Download Feature
- New Service Integration
- Metadata/Tagging
- Performance
- Settings/Configuration
- Other
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request
placeholder: Add any other context or screenshots...
+7 -9
View File
@@ -93,11 +93,12 @@ jobs:
# Accept licenses # Accept licenses
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK (required for gomobile) # Install NDK r27d LTS (required for 16KB page size support on Android 15+)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0" # Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
# Set NDK path # Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
- name: Install gomobile - name: Install gomobile
run: | run: |
@@ -144,7 +145,7 @@ jobs:
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }}
env: env:
BUILD_TOOLS_VERSION: "34.0.0" BUILD_TOOLS_VERSION: "36.0.0"
- name: Rename APKs - name: Rename APKs
run: | run: |
@@ -382,20 +383,17 @@ jobs:
### Downloads ### Downloads
#### Android #### Android
![Android arm64 Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=for-the-badge&logo=android&label=arm64&color=3DDC84)
![Android arm32 Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=for-the-badge&logo=android&label=arm32&color=3DDC84)
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices) - **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices) - **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
#### iOS #### iOS
![iOS Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=for-the-badge&logo=apple&label=iOS&color=0078D6)
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required) - **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
### Installation ### Installation
**Android**: Enable "Install from unknown sources" and install the APK **Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA **iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER FOOTER
echo "Release body:" echo "Release body:"
+3
View File
@@ -13,6 +13,9 @@ Thumbs.db
# Reference folder (development only) # Reference folder (development only)
referensi/ referensi/
# Documentation (hosted separately)
docs/
# Old spotiflac_android folder (moved to root) # Old spotiflac_android folder (moved to root)
spotiflac_android/ spotiflac_android/
+268
View File
@@ -1,5 +1,273 @@
# Changelog # Changelog
## [2.2.9] - 2026-01-12
### Fixed
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- Replaces existing entry and moves to top of list
- Auto-deduplicates existing history on app load
- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error
- Now shows proper message: "Cannot write to folder, check storage permission"
- Added `permission` error type detection in backend
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
- Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
- `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear)
- `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear)
- Proper permission check before showing "granted" status
---
## [2.2.8] - 2026-01-12
### Added
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
- Select multiple tracks at once
- "Select All" and "Delete Selected" actions
- Modern Material 3 bottom action bar (slides up from bottom)
- Works in both grid and list view modes
- **History Filter Tabs**: Filter history by All/Albums/Singles
- Album = tracks where album has >1 track in history
- Single = tracks where album has only 1 track in history
- Filter chips show counts for each category
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
- Album cards displayed in 2-column grid with cover art and track count badge
- Tap album to open dedicated album detail screen
- Album detail shows all downloaded tracks from that album
- Multi-select delete support within album view
- Auto-navigates back when album has <2 tracks remaining
### Changed
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
## [2.2.7] - 2026-01-11
### Added
- **CSV Import Metadata Enrichment**: Tracks imported from CSV now automatically fetch metadata from Deezer
- Cover art, duration, track/disc number fetched via ISRC lookup
- Fallback to text search (artist + track name) when ISRC not found in Deezer
- Progress dialog shows enrichment status during import
- Ensures downloaded files have proper cover art and metadata
- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks
- "Open in Deezer" button for Deezer-sourced tracks (opens app or web)
- Displays "Deezer ID" instead of "Spotify ID" when applicable
- **Smart Tag Injection**: Filename format editor intelligently handles separators
- Auto-detects if " - " is needed between tags
- Prevents double separators or missing spaces
- **Dynamic Source Info**: Search source selector now shows helpful context
- "No login required" for Deezer
- "Requires credentials" for Spotify
### Changed
- **UI Modernization**: Major UI consistency updates across the app
- **Unified App Bars**: Home, History, and Settings now share identical behavior
- Lowered expanded header for easier one-handed reachability
- Dynamic title text scaling (20px to 34px)
- **Appearance Settings**: Completely redesigned appearance page
- New "Theme Preview" card showing visualizing current theme
- Modern color palette picker replacing old color dots
- Clean, grouped layout
- "AMOLED Dark" switch is now hidden when using Light Mode
- **App Logo**: Refined logo style on Home and About screens
- Inverted colors: Filled primary color circle with on-color icon
- Removed padding for a cleaner, bolder look
- **Material 3 Switches**: Added checkmark icon to active switches
- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look
- **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields
- **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest`
- **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius)
- **Options Settings Redesign**: improved layout and usability
- **Search Source Priority**: Moved "Search Source" section to the very top for quick access
- **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent
- **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing
- **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret
- **Filename Format Editor 2.0**:
- **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet
- **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion
- **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing
### Fixed
- **CSV Import Missing Cover Art**: Fixed tracks from CSV having no cover art in download history
- Cover URL now properly fetched from Deezer during enrichment
- Falls back to text search when ISRC lookup fails
- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks
- Duration now fetched from Deezer metadata during enrichment
- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen
- Changed condition from `discNumber > 0` to `discNumber > 0`
- Now displays disc 1 instead of hiding it
- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data
- Now uses `trackToDownload` (enriched) instead of `item.track` (original)
### Technical
- Updated `lib/services/csv_import_service.dart`:
- Added `_enrichTracksMetadata()` with ISRC lookup + text search fallback
- Added progress callback for UI feedback
- Updated `lib/screens/home_tab.dart`:
- Added progress dialog during CSV enrichment
- Updated `lib/providers/download_queue_provider.dart`:
- Uses enriched track data for download history
- Updated `lib/screens/track_metadata_screen.dart`:
- Show disc number when > 0 (was > 1)
- Updated `go_backend/metadata.go`:
- Added `TotalSamples` to `AudioQuality` struct for duration calculation
- Updated `go_backend/exports.go`:
- `ReadFileMetadata` now returns duration calculated from FLAC stream info
- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions
- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities
- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling
---
## [2.2.6] - 2026-01-11
### Fixed
- **Release Mode Logging**: Flutter app logs now properly captured in release builds
- Previously only Go backend logs appeared when "Detailed Logging" was enabled
- Now both Flutter and Go logs are captured in release mode
- Bypasses Logger package which filters logs in release mode
### Added
- **Detailed Deezer Search Logging**: Better debugging for search issues
- Logs API URLs, response counts, and errors
- Helps diagnose geo-restriction and API issues
- Detects Deezer API error responses
### Changed
- **Home Screen Logo**: Replaced music note icon with app logo
- Uses `assets/images/logo.png`
- Rounded corners (24px radius)
- Fallback to music note icon if logo fails to load
- **About Page Logo**: Removed shadow/border from logo
- Cleaner appearance without background container
- **About Page Icon Alignment**: Icons now aligned with contributor avatars
- DoubleDouble and DAB Music icons use 40x40 area
- Text now properly aligned with contributor items
## [2.2.5] - 2026-01-10
### Added
- **In-App Log Viewer with Go Backend Logs**: Complete logging system for debugging
- Go backend logs now captured and displayed in app
- Circular buffer stores up to 500 log entries
- Real-time polling (500ms) for Go backend logs
- Logs include timestamp, level, tag, and message
- "Go" badge indicates logs from backend
- **Detailed Logging Toggle**: Control logging in Settings > Options > Debug
- Disabled by default for performance
- Errors are always logged regardless of setting
- Enable before reproducing bugs for detailed logs
- **Log Issue Summary**: Automatic detection of common issues in logs
- ISP Blocking detection with affected domains
- Rate limiting detection
- Network error detection
- Track not found detection
- Shows suggestions for each issue type
- **ISP Blocking Detection**: Detects when ISP blocks download services
- DNS resolution failure detection
- Connection reset/refused detection
- TLS handshake failure detection
- HTTP 403/451 blocking page detection
- Suggests VPN or DNS change (1.1.1.1 / 8.8.8.8)
### Fixed
- **Artist Profile Placeholder**: Shows person icon when artist has no profile image
- Validates image URL before loading
- Fallback icon on load error
- **Latin Extended Character Detection**: Fixed wrong track downloads for Polish, Czech, French, Spanish songs
- Characters like Ł, ę, ć, ñ, é now correctly treated as Latin script
- Previously treated as "different script" causing false matches
- Affects both Tidal and Qobuz search
### Changed
- **Log Screen UI Improvements**:
- Copy button moved to app bar (left of menu)
- Removed redundant info card
- Cleaner interface
- **Issue Templates Updated**: Instructions for enabling detailed logging before submitting bug reports
### Technical
- New file: `go_backend/logbuffer.go` with circular buffer and GoLog function
- Updated `go_backend/httputil.go` with ISP blocking detection
- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with `isLatinScript()` function
- Updated `lib/utils/logger.dart` with Go log polling
- Updated `lib/screens/settings/log_screen.dart` with issue summary
- Added method channel handlers for logging in Android and iOS
- New error type: `isp_blocked` for ISP blocking errors
---
## [2.2.0] - 2026-01-10
### Fixed
- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application.
- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request.
- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames.
- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes.
- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error
- Added proper null checks when parsing Go backend response
- Added type checking for track data before parsing
- **Duration Calculation in Enrichment**: Fixed duration conversion bug
- Go backend returns `duration_ms` (milliseconds)
- Now properly converts to seconds for Track model
### Changed
- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**.
- Tidal is now the default download service (was Qobuz)
- Tidal has faster and more reliable ISRC matching
- Existing users need to change setting manually or clear app data
- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source.
### Added
- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks.
- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy.
- Returns as soon as ISRC match is found in first query results
- Significantly faster for tracks with valid ISRC
- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download
- Search results don't include ISRC (for performance)
- ISRC is now fetched via metadata enrichment when download starts
- Ensures accurate track matching on all streaming services
- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism.
- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging
- Shows when ISRC search is attempted
- Shows number of results and exact ISRC matches found
### Technical
- Updated `go_backend/tidal.go`:
- Early exit optimization in `SearchTrackByMetadataWithISRC()`
- Deezer ID support in SongLink lookup
- Updated `go_backend/qobuz.go`:
- Added logging for ISRC search flow
- Duration tolerance reduced from 30s to 10s
- Updated `go_backend/exports.go`:
- Default service order changed to `[tidal, qobuz, amazon]`
- Updated `lib/providers/download_queue_provider.dart`:
- ISRC-based enrichment condition
- Null-safe parsing of Go backend response
- Updated `lib/services/platform_bridge.dart`:
- Null check for `getDeezerMetadata` result
- Updated `lib/models/settings.dart`:
- Default service changed to `tidal`
---
## [2.1.7] - 2026-01-09 ## [2.1.7] - 2026-01-09
### Added ### Added
+1 -1
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/ca16289599f71b8e50d3726a8c64a202ea922a1893bcf21b9eca1a050736f1f5/) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
<div align="center"> <div align="center">
+1 -1
View File
@@ -46,7 +46,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.zarz.spotiflac" applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = 34 targetSdk = 36
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
multiDexEnabled = true multiDexEnabled = true
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
/** /**
* Foreground service to keep downloads running when app is in background. * Foreground service to keep downloads running when app is in background.
* This prevents Android from killing the download process or throttling network. * This prevents Android from killing the download process or throttling network.
*
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
*/ */
class DownloadService : Service() { class DownloadService : Service() {
@@ -106,6 +109,19 @@ class DownloadService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
/**
* Called when the foreground service timeout is reached (Android 15+, API 35+).
* dataSync services have a 6-hour limit in a 24-hour period.
* We must call stopSelf() within a few seconds to avoid a crash.
*/
override fun onTimeout(startId: Int, fgsType: Int) {
// Log the timeout for debugging
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
// Gracefully stop the service
stopForegroundService()
}
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
@@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() {
} }
result.success(null) result.success(null)
} }
"readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.readFileMetadata(filePath)
}
result.success(response)
}
"startDownloadService" -> { "startDownloadService" -> {
val trackName = call.argument<String>("track_name") ?: "" val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: "" val artistName = call.argument<String>("artist_name") ?: ""
@@ -277,6 +284,39 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) result.success(response)
} }
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getLogs()
}
result.success(response)
}
"getLogsSince" -> {
val index = call.argument<Int>("index") ?: 0
val response = withContext(Dispatchers.IO) {
Gobackend.getLogsSince(index.toLong())
}
result.success(response)
}
"clearLogs" -> {
withContext(Dispatchers.IO) {
Gobackend.clearLogs()
}
result.success(null)
}
"getLogCount" -> {
val count = withContext(Dispatchers.IO) {
Gobackend.getLogCount()
}
result.success(count.toInt())
}
"setLoggingEnabled" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
withContext(Dispatchers.IO) {
Gobackend.setLoggingEnabled(enabled)
}
result.success(null)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} catch (e: Exception) { } catch (e: Exception) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+80 -48
View File
@@ -18,7 +18,7 @@ import (
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC) // AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client client *http.Client
regions []string // us, eu regions for DoubleDouble service regions []string // us, eu regions for DoubleDouble service
lastAPICallTime time.Time // Rate limiting: track last API call lastAPICallTime time.Time // Rate limiting: track last API call
apiCallCount int // Rate limiting: counter per minute apiCallCount int // Rate limiting: counter per minute
apiCallResetTime time.Time // Rate limiting: reset time apiCallResetTime time.Time // Rate limiting: reset time
@@ -52,46 +52,46 @@ type DoubleDoubleStatusResponse struct {
func amazonArtistsMatch(expectedArtist, foundArtist string) bool { func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match // Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other // Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
// Check first artist (before comma or feat) // Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0] expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0] expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0] expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst) expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0] foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0] foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0] foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst) foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst { if expectedFirst == foundFirst {
return true return true
} }
// Check if first artist is contained in the other // Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true return true
} }
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration // assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist) expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist) foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII { if expectedASCII != foundASCII {
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true return true
} }
return false return false
} }
@@ -124,7 +124,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
defer amazonRateLimitMu.Unlock() defer amazonRateLimitMu.Unlock()
now := time.Now() now := time.Now()
// Reset counter every minute // Reset counter every minute
if now.Sub(a.apiCallResetTime) >= time.Minute { if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0 a.apiCallCount = 0
@@ -135,7 +135,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
if a.apiCallCount >= 9 { if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime) waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 { if waitTime > 0 {
fmt.Printf("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime) time.Sleep(waitTime)
a.apiCallCount = 0 a.apiCallCount = 0
a.apiCallResetTime = time.Now() a.apiCallResetTime = time.Now()
@@ -148,7 +148,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
minDelay := 7 * time.Second minDelay := 7 * time.Second
if timeSinceLastCall < minDelay { if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall waitTime := minDelay - timeSinceLastCall
fmt.Printf("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime) time.Sleep(waitTime)
} }
} }
@@ -170,7 +170,6 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC) // downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
// This uses submit → poll → download mechanism // This uses submit → poll → download mechanism
// Internal function - not exported to gomobile // Internal function - not exported to gomobile
@@ -178,11 +177,11 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
var lastError error var lastError error
for _, region := range a.regions { for _, region := range a.regions {
fmt.Printf("[Amazon] Trying region: %s...\n", region) GoLog("[Amazon] Trying region: %s...\n", region)
// Build base URL for DoubleDouble service // Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC) // Decode base64 service URL (same as PC)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https:// serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
@@ -202,7 +201,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
fmt.Println("[Amazon] Submitting download request...") fmt.Println("[Amazon] Submitting download request...")
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait) // Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
var resp *http.Response var resp *http.Response
maxRetries := 3 maxRetries := 3
@@ -217,7 +216,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
resp.Body.Close() resp.Body.Close()
if retry < maxRetries-1 { if retry < maxRetries-1 {
waitTime := 15 * time.Second waitTime := 15 * time.Second
fmt.Printf("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries) GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
time.Sleep(waitTime) time.Sleep(waitTime)
continue continue
} }
@@ -256,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
} }
downloadID := submitResp.ID downloadID := submitResp.ID
fmt.Printf("[Amazon] Download ID: %s\n", downloadID) GoLog("[Amazon] Download ID: %s\n", downloadID)
// Step 2: Poll for completion // Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
@@ -311,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
trackName := status.Current.Name trackName := status.Current.Name
artist := status.Current.Artist artist := status.Current.Artist
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName) GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
return fileURL, trackName, artist, nil return fileURL, trackName, artist, nil
} else if status.Status == "error" { } else if status.Status == "error" {
@@ -345,7 +344,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
// DownloadFile downloads a file from URL with User-Agent and progress tracking // DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Initialize item progress (required for all downloads) // Initialize item progress (required for all downloads)
@@ -434,6 +432,7 @@ type AmazonDownloadResult struct {
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string
} }
// downloadFromAmazon downloads a track using the request parameters // downloadFromAmazon downloads a track using the request parameters
@@ -450,12 +449,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
var availability *TrackAvailability var availability *TrackAvailability
var err error var err error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx") // Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") { if strings.HasPrefix(req.SpotifyID, "deezer:") {
// Extract Deezer ID and use Deezer-based lookup // Extract Deezer ID and use Deezer-based lookup
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
fmt.Printf("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" { } else if req.SpotifyID != "" {
// Use Spotify ID // Use Spotify ID
@@ -463,7 +462,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} else { } else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
} }
if err != nil { if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
} }
@@ -487,12 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Verify artist matches // Verify artist matches
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) { if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName) GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName) return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
} }
// Log match found // Log match found
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName) GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate) // Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
@@ -543,19 +542,38 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Log track info from DoubleDouble (for debugging) // Log track info from DoubleDouble (for debugging)
if trackName != "" && artistName != "" { if trackName != "" && artistName != "" {
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
// Read existing metadata from downloaded file BEFORE embedding
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
if metaErr == nil && existingMeta != nil {
// Use file metadata if it has valid track/disc numbers and request doesn't have them
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
} }
// Embed metadata using Spotify data (more accurate than DoubleDouble) // Embed metadata using Spotify data (more accurate than DoubleDouble)
// But preserve track/disc numbers from file if they were better
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, Date: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, DiscNumber: actualDiscNum,
ISRC: req.ISRC, ISRC: req.ISRC,
} }
@@ -563,7 +581,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
var coverData []byte var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil { if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData coverData = parallelResult.CoverData
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} }
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
@@ -572,9 +590,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Embed lyrics from parallel fetch // Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else { } else {
fmt.Println("[Amazon] Lyrics embedded successfully") fmt.Println("[Amazon] Lyrics embedded successfully")
} }
@@ -583,36 +601,50 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music") fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
// Read actual quality from the downloaded FLAC file // Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself // Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath) quality, err := GetAudioQuality(outputPath)
if err != nil { if err != nil {
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err) GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
// Add to ISRC index for fast duplicate checking } else {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
// Return 0 to indicate unknown quality }
return AmazonDownloadResult{
FilePath: outputPath, // Read metadata from file AFTER embedding to get accurate values
BitDepth: 0, // This ensures we return what's actually in the file
SampleRate: 0, finalMeta, metaReadErr := ReadMetadata(outputPath)
}, nil if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
// Use date from file if available
req.ReleaseDate = finalMeta.Date
}
} }
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
// Add to ISRC index for fast duplicate checking // Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
bitDepth := 0
sampleRate := 0
if err == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
return AmazonDownloadResult{ return AmazonDownloadResult{
FilePath: outputPath, FilePath: outputPath,
BitDepth: quality.BitDepth, BitDepth: bitDepth,
SampleRate: quality.SampleRate, SampleRate: sampleRate,
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
ReleaseDate: req.ReleaseDate, ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: actualTrackNum,
DiscNumber: req.DiscNumber, DiscNumber: actualDiscNum,
ISRC: req.ISRC,
}, nil }, nil
} }
+98 -65
View File
@@ -19,9 +19,9 @@ const (
deezerAlbumURL = deezerBaseURL + "/album/%s" deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s" deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s" deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute deezerCacheTTL = 10 * time.Minute
// Parallel ISRC fetching settings // Parallel ISRC fetching settings
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
) )
@@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient {
// Deezer API response types // Deezer API response types
type deezerTrack struct { type deezerTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Duration int `json:"duration"` // in seconds Duration int `json:"duration"` // in seconds
TrackPosition int `json:"track_position"` TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"` DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Link string `json:"link"` Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level ReleaseDate string `json:"release_date"` // Sometimes at track level
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"` Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
} }
type deezerArtist struct { type deezerArtist struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Picture string `json:"picture"` Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"` PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"` PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"` PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"` NbFan int `json:"nb_fan"`
} }
type deezerAlbumSimple struct { type deezerAlbumSimple struct {
@@ -90,6 +90,7 @@ type deezerAlbumSimple struct {
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level ReleaseDate string `json:"release_date"` // Sometimes at album level
} }
// ... (skip other structs as they are fine/unchanged) ... // ... (skip other structs as they are fine/unchanged) ...
// ... (in convertTrack) ... // ... (in convertTrack) ...
@@ -113,7 +114,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
if albumImage == "" { if albumImage == "" {
albumImage = track.Album.Cover albumImage = track.Album.Cover
} }
// Try to find release date // Try to find release date
releaseDate := track.ReleaseDate releaseDate := track.ReleaseDate
if releaseDate == "" { if releaseDate == "" {
@@ -137,17 +138,17 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
} }
type deezerAlbumFull struct { type deezerAlbumFull struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Cover string `json:"cover"` Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
Tracks struct { Tracks struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -164,17 +165,17 @@ type deezerArtistFull struct {
} }
type deezerPlaylistFull struct { type deezerPlaylistFull struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Picture string `json:"picture"` Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"` PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"` PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"` PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
Creator struct { Creator struct {
Name string `json:"name"` Name string `json:"name"`
} `json:"creator"` } `json:"creator"`
Tracks struct { Tracks struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -182,11 +183,14 @@ type deezerPlaylistFull struct {
// SearchAll searches for tracks and artists on Deezer // SearchAll searches for tracks and artists on Deezer
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download // NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit) cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
GoLog("[Deezer] SearchAll: returning cached result\n")
return entry.data.(*SearchAllResult), nil return entry.data.(*SearchAllResult), nil
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
@@ -198,13 +202,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
// Search tracks - NO ISRC fetch for performance // Search tracks - NO ISRC fetch for performance
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
var trackResp struct { var trackResp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
} }
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err) return nil, fmt.Errorf("deezer track search failed: %w", err)
} }
if trackResp.Error != nil {
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
}
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data { for _, track := range trackResp.Data {
// Convert directly without fetching ISRC - much faster // Convert directly without fetching ISRC - much faster
result.Tracks = append(result.Tracks, c.convertTrack(track)) result.Tracks = append(result.Tracks, c.convertTrack(track))
@@ -212,21 +231,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
// Search artists // Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct { var artistResp struct {
Data []deezerArtist `json:"data"` Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
} }
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil { if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
for _, artist := range artistResp.Data { if artistResp.Error != nil {
result.Artists = append(result.Artists, SearchArtistResult{ GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
ID: fmt.Sprintf("deezer:%d", artist.ID), } else {
Name: artist.Name, GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
Images: c.getBestArtistImage(artist), for _, artist := range artistResp.Data {
Followers: artist.NbFan, result.Artists = append(result.Artists, SearchArtistResult{
Popularity: 0, ID: fmt.Sprintf("deezer:%d", artist.ID),
}) Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
} }
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
} }
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Cache result // Cache result
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
@@ -241,7 +276,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
// GetTrack fetches a single track by Deezer ID // GetTrack fetches a single track by Deezer ID
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID) trackURL := fmt.Sprintf(deezerTrackURL, trackID)
var track deezerTrack var track deezerTrack
if err := c.getJSON(ctx, trackURL, &track); err != nil { if err := c.getJSON(ctx, trackURL, &track); err != nil {
return nil, err return nil, err
@@ -263,7 +298,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID) albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil { if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, err return nil, err
@@ -375,7 +410,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
if albumType == "compile" { if albumType == "compile" {
albumType = "compilation" albumType = "compilation"
} }
coverURL := album.CoverXL coverURL := album.CoverXL
if coverURL == "" { if coverURL == "" {
coverURL = album.CoverBig coverURL = album.CoverBig
@@ -418,7 +453,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
// ISRC is fetched in parallel for better performance // ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
var playlist deezerPlaylistFull var playlist deezerPlaylistFull
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil { if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
return nil, err return nil, err
@@ -482,7 +517,7 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
// Use direct ISRC endpoint (API 2.0) // Use direct ISRC endpoint (API 2.0)
// https://api.deezer.com/2.0/track/isrc:{ISRC} // https://api.deezer.com/2.0/track/isrc:{ISRC}
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil { if err := c.getJSON(ctx, directURL, &track); err != nil {
// Fallback to search if direct endpoint fails // Fallback to search if direct endpoint fails
@@ -522,7 +557,7 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string) result := make(map[string]string)
var resultMu sync.Mutex var resultMu sync.Mutex
// First, check cache for existing ISRCs // First, check cache for existing ISRCs
var tracksToFetch []deezerTrack var tracksToFetch []deezerTrack
c.cacheMu.RLock() c.cacheMu.RLock()
@@ -535,20 +570,20 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
} }
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
if len(tracksToFetch) == 0 { if len(tracksToFetch) == 0 {
return result return result
} }
// Use semaphore to limit concurrent requests // Use semaphore to limit concurrent requests
sem := make(chan struct{}, deezerMaxParallelISRC) sem := make(chan struct{}, deezerMaxParallelISRC)
var wg sync.WaitGroup var wg sync.WaitGroup
for _, track := range tracksToFetch { for _, track := range tracksToFetch {
wg.Add(1) wg.Add(1)
go func(t deezerTrack) { go func(t deezerTrack) {
defer wg.Done() defer wg.Done()
// Acquire semaphore // Acquire semaphore
select { select {
case sem <- struct{}{}: case sem <- struct{}{}:
@@ -556,24 +591,24 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
case <-ctx.Done(): case <-ctx.Done():
return return
} }
trackIDStr := fmt.Sprintf("%d", t.ID) trackIDStr := fmt.Sprintf("%d", t.ID)
fullTrack, err := c.fetchFullTrack(ctx, trackIDStr) fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
if err != nil || fullTrack == nil { if err != nil || fullTrack == nil {
return return
} }
// Store in result and cache // Store in result and cache
resultMu.Lock() resultMu.Lock()
result[trackIDStr] = fullTrack.ISRC result[trackIDStr] = fullTrack.ISRC
resultMu.Unlock() resultMu.Unlock()
c.cacheMu.Lock() c.cacheMu.Lock()
c.isrcCache[trackIDStr] = fullTrack.ISRC c.isrcCache[trackIDStr] = fullTrack.ISRC
c.cacheMu.Unlock() c.cacheMu.Unlock()
}(track) }(track)
} }
wg.Wait() wg.Wait()
return result return result
} }
@@ -588,23 +623,21 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
return isrc, nil return isrc, nil
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch from API // Fetch from API
fullTrack, err := c.fetchFullTrack(ctx, trackID) fullTrack, err := c.fetchFullTrack(ctx, trackID)
if err != nil { if err != nil {
return "", err return "", err
} }
// Cache the result // Cache the result
c.cacheMu.Lock() c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC c.isrcCache[trackID] = fullTrack.ISRC
c.cacheMu.Unlock() c.cacheMu.Unlock()
return fullTrack.ISRC, nil return fullTrack.ISRC, nil
} }
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string { func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
if artist.PictureXL != "" { if artist.PictureXL != "" {
return artist.PictureXL return artist.PictureXL
@@ -687,7 +720,7 @@ func parseDeezerURL(input string) (string, string, error) {
} }
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Skip language prefix if present (e.g., /en/, /fr/) // Skip language prefix if present (e.g., /en/, /fr/)
if len(parts) > 0 && len(parts[0]) == 2 { if len(parts) > 0 && len(parts[0]) == 2 {
parts = parts[1:] parts = parts[1:]
+196 -105
View File
@@ -17,17 +17,17 @@ func ParseSpotifyURL(url string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
result := map[string]string{ result := map[string]string{
"type": parsed.Type, "type": parsed.Type,
"id": parsed.ID, "id": parsed.ID,
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -42,18 +42,18 @@ func SetSpotifyAPICredentials(clientID, clientSecret string) {
func GetSpotifyMetadata(spotifyURL string) (string, error) { func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(data) jsonBytes, err := json.Marshal(data)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -62,18 +62,18 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
func SearchSpotify(query string, limit int) (string, error) { func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
results, err := client.SearchTracks(ctx, query, limit) results, err := client.SearchTracks(ctx, query, limit)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(results) jsonBytes, err := json.Marshal(results)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -82,18 +82,18 @@ func SearchSpotify(query string, limit int) (string, error) {
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(results) jsonBytes, err := json.Marshal(results)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -105,12 +105,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(availability) jsonBytes, err := json.Marshal(availability)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -133,7 +133,7 @@ type DownloadRequest struct {
DiscNumber int `json:"disc_number"` DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
} }
@@ -155,6 +155,7 @@ type DownloadResponse struct {
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
} }
// DownloadResult is a generic result type for all downloaders // DownloadResult is a generic result type for all downloaders
@@ -169,6 +170,7 @@ type DownloadResult struct {
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string
} }
// DownloadTrack downloads a track from the specified service // DownloadTrack downloads a track from the specified service
@@ -179,17 +181,17 @@ func DownloadTrack(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error()) return errorResponse("Invalid request: " + err.Error())
} }
// Trim whitespace from string fields to prevent filename/path issues // Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName) req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName) req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
var result DownloadResult var result DownloadResult
var err error var err error
switch req.Service { switch req.Service {
case "tidal": case "tidal":
tidalResult, tidalErr := downloadFromTidal(req) tidalResult, tidalErr := downloadFromTidal(req)
@@ -204,6 +206,7 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: tidalResult.ReleaseDate, ReleaseDate: tidalResult.ReleaseDate,
TrackNumber: tidalResult.TrackNumber, TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber, DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
} }
} }
err = tidalErr err = tidalErr
@@ -220,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: qobuzResult.ReleaseDate, ReleaseDate: qobuzResult.ReleaseDate,
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
} }
} }
err = qobuzErr err = qobuzErr
@@ -236,17 +240,18 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
} }
} }
err = amazonErr err = amazonErr
default: default:
return errorResponse("Unknown service: " + req.Service) return errorResponse("Unknown service: " + req.Service)
} }
if err != nil { if err != nil {
return errorResponse(err.Error()) return errorResponse(err.Error())
} }
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:] actualPath := result.FilePath[7:]
@@ -264,21 +269,28 @@ func DownloadTrack(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: req.Service, Service: req.Service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API) // Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath) quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil { if qErr == nil {
result.BitDepth = quality.BitDepth result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else { } else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) GoLog("[Download] Could not read quality from file: %v\n", qErr)
} }
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Download complete", Message: "Download complete",
@@ -292,8 +304,9 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: result.ReleaseDate, ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -305,23 +318,23 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error()) return errorResponse("Invalid request: " + err.Error())
} }
// Trim whitespace from string fields to prevent filename/path issues // Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName) req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName) req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
// Build service order starting with preferred service // Build service order starting with preferred service
allServices := []string{"qobuz", "tidal", "amazon"} allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service preferredService := req.Service
if preferredService == "" { if preferredService == "" {
preferredService = "tidal" preferredService = "tidal"
} }
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service) GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
// Create ordered list: preferred first, then others // Create ordered list: preferred first, then others
services := []string{preferredService} services := []string{preferredService}
for _, s := range allServices { for _, s := range allServices {
@@ -329,18 +342,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
services = append(services, s) services = append(services, s)
} }
} }
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services) GoLog("[DownloadWithFallback] Service order: %v\n", services)
var lastErr error var lastErr error
for _, service := range services { for _, service := range services {
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service) GoLog("[DownloadWithFallback] Trying service: %s\n", service)
req.Service = service req.Service = service
var result DownloadResult var result DownloadResult
var err error var err error
switch service { switch service {
case "tidal": case "tidal":
tidalResult, tidalErr := downloadFromTidal(req) tidalResult, tidalErr := downloadFromTidal(req)
@@ -355,21 +368,29 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ReleaseDate: tidalResult.ReleaseDate, ReleaseDate: tidalResult.ReleaseDate,
TrackNumber: tidalResult.TrackNumber, TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber, DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
} }
} else { } else {
fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr) GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
} }
err = tidalErr err = tidalErr
case "qobuz": case "qobuz":
qobuzResult, qobuzErr := downloadFromQobuz(req) qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil { if qobuzErr == nil {
result = DownloadResult{ result = DownloadResult{
FilePath: qobuzResult.FilePath, FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth, BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate, SampleRate: qobuzResult.SampleRate,
Title: qobuzResult.Title,
Artist: qobuzResult.Artist,
Album: qobuzResult.Album,
ReleaseDate: qobuzResult.ReleaseDate,
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
} }
} else { } else {
fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
} }
err = qobuzErr err = qobuzErr
case "amazon": case "amazon":
@@ -385,13 +406,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
} }
} else { } else {
fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr) GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
} }
err = amazonErr err = amazonErr
} }
if err == nil { if err == nil {
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
@@ -410,21 +432,28 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: service, Service: service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API) // Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath) quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil { if qErr == nil {
result.BitDepth = quality.BitDepth result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else { } else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) GoLog("[Download] Could not read quality from file: %v\n", qErr)
} }
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Downloaded from " + service, Message: "Downloaded from " + service,
@@ -432,14 +461,21 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: service, Service: service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
lastErr = err lastErr = err
} }
return errorResponse("All services failed. Last error: " + lastErr.Error()) return errorResponse("All services failed. Last error: " + lastErr.Error())
} }
@@ -477,6 +513,51 @@ func CleanupConnections() {
CloseIdleConnections() CloseIdleConnections()
} }
// ReadFileMetadata reads metadata directly from a FLAC file
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
// This is useful for displaying accurate metadata in the UI without relying on cached data
func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
// Also get audio quality info
quality, qualityErr := GetAudioQuality(filePath)
// Get duration from FLAC stream info
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
result := map[string]interface{}{
"title": metadata.Title,
"artist": metadata.Artist,
"album": metadata.Album,
"album_artist": metadata.AlbumArtist,
"date": metadata.Date,
"track_number": metadata.TrackNumber,
"disc_number": metadata.DiscNumber,
"isrc": metadata.ISRC,
"lyrics": metadata.Lyrics,
"duration": duration,
}
// Add quality info if available
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SetDownloadDirectory sets the default download directory // SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
@@ -485,17 +566,17 @@ func SetDownloadDirectory(path string) error {
// CheckDuplicate checks if a file with the given ISRC exists // CheckDuplicate checks if a file with the given ISRC exists
func CheckDuplicate(outputDir, isrc string) (string, error) { func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc) existingFile, exists := CheckISRCExists(outputDir, isrc)
result := map[string]interface{}{ result := map[string]interface{}{
"exists": exists, "exists": exists,
"filepath": existingFile, "filepath": existingFile,
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -525,7 +606,7 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return "", err return "", err
} }
filename := buildFilenameFromTemplate(template, metadata) filename := buildFilenameFromTemplate(template, metadata)
return filename, nil return filename, nil
} }
@@ -655,18 +736,18 @@ func ClearTrackIDCache() {
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
client := GetDeezerClient() client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(results) jsonBytes, err := json.Marshal(results)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -676,11 +757,11 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error)
func GetDeezerMetadata(resourceType, resourceID string) (string, error) { func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
client := GetDeezerClient() client := GetDeezerClient()
var data interface{} var data interface{}
var err error var err error
switch resourceType { switch resourceType {
case "track": case "track":
data, err = client.GetTrack(ctx, resourceID) data, err = client.GetTrack(ctx, resourceID)
@@ -693,16 +774,16 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
default: default:
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
} }
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(data) jsonBytes, err := json.Marshal(data)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -712,17 +793,17 @@ func ParseDeezerURLExport(url string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
result := map[string]string{ result := map[string]string{
"type": resourceType, "type": resourceType,
"id": resourceID, "id": resourceID,
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -730,18 +811,18 @@ func ParseDeezerURLExport(url string) (string, error) {
func SearchDeezerByISRC(isrc string) (string, error) { func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
client := GetDeezerClient() client := GetDeezerClient()
track, err := client.SearchByISRC(ctx, isrc) track, err := client.SearchByISRC(ctx, isrc)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(track) jsonBytes, err := json.Marshal(track)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -751,52 +832,52 @@ func SearchDeezerByISRC(isrc string) (string, error) {
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
deezerClient := GetDeezerClient() deezerClient := GetDeezerClient()
// For tracks, we can use SongLink to get Deezer ID // For tracks, we can use SongLink to get Deezer ID
if resourceType == "track" { if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID) deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil { if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err) return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
} }
// Fetch metadata from Deezer // Fetch metadata from Deezer
trackResp, err := deezerClient.GetTrack(ctx, deezerID) trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err) return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
} }
jsonBytes, err := json.Marshal(trackResp) jsonBytes, err := json.Marshal(trackResp)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// For albums, SongLink also provides mapping // For albums, SongLink also provides mapping
if resourceType == "album" { if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID) deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil { if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err) return "", fmt.Errorf("could not find Deezer album: %w", err)
} }
// Fetch album metadata from Deezer // Fetch album metadata from Deezer
albumResp, err := deezerClient.GetAlbum(ctx, deezerID) albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err) return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
} }
jsonBytes, err := json.Marshal(albumResp) jsonBytes, err := json.Marshal(albumResp)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// For artists/playlists, SongLink doesn't provide direct mapping // For artists/playlists, SongLink doesn't provide direct mapping
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType) return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
} }
@@ -805,7 +886,7 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
// Try Spotify first // Try Spotify first
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
@@ -816,32 +897,32 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Check if it's a rate limit error // Check if it's a rate limit error
errStr := strings.ToLower(err.Error()) errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
// Not a rate limit error, return original error // Not a rate limit error, return original error
return "", err return "", err
} }
// Rate limited - try Deezer fallback for tracks and albums // Rate limited - try Deezer fallback for tracks and albums
parsed, parseErr := parseSpotifyURI(spotifyURL) parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil { if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr) return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
} }
fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type) GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" { if parsed.Type == "track" || parsed.Type == "album" {
// Convert to Deezer // Convert to Deezer
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID) return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
} }
// Artist and playlist not supported for fallback // Artist and playlist not supported for fallback
if parsed.Type == "artist" { if parsed.Type == "artist" {
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later") return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
} }
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
} }
@@ -855,12 +936,12 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(availability) jsonBytes, err := json.Marshal(availability)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -874,12 +955,12 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(availability) jsonBytes, err := json.Marshal(availability)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -905,24 +986,34 @@ func errorResponse(msg string) (string, error) {
// Determine error type based on message // Determine error type based on message
errorType := "unknown" errorType := "unknown"
lowerMsg := strings.ToLower(msg) lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "not found") || if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "not available") || strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "no results") || strings.Contains(lowerMsg, "change dns") {
strings.Contains(lowerMsg, "track not found") || errorType = "isp_blocked"
strings.Contains(lowerMsg, "all services failed") { } else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
errorType = "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
errorType = "not_found" errorType = "not_found"
} else if strings.Contains(lowerMsg, "rate limit") || } else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") || strings.Contains(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") { strings.Contains(lowerMsg, "too many requests") {
errorType = "rate_limit" errorType = "rate_limit"
} else if strings.Contains(lowerMsg, "network") || } else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") || strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") || strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") { strings.Contains(lowerMsg, "dial") {
errorType = "network" errorType = "network"
} }
resp := DownloadResponse{ resp := DownloadResponse{
Success: false, Success: false,
Error: msg, Error: msg,
+218 -1
View File
@@ -1,12 +1,17 @@
package gobackend package gobackend
import ( import (
"crypto/tls"
"errors"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings"
"syscall"
"time" "time"
) )
@@ -134,9 +139,15 @@ func CloseIdleConnections() {
} }
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header // DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
return client.Do(req) resp, err := client.Do(req)
if err != nil {
// Check for ISP blocking
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
} }
// RetryConfig holds configuration for retry logic // RetryConfig holds configuration for retry logic
@@ -159,9 +170,11 @@ func DefaultRetryConfig() RetryConfig {
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff // DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
// Handles 429 (Too Many Requests) responses with Retry-After header // Handles 429 (Too Many Requests) responses with Retry-After header
// Also detects and logs ISP blocking
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error var lastErr error
delay := config.InitialDelay delay := config.InitialDelay
requestURL := req.URL.String()
for attempt := 0; attempt <= config.MaxRetries; attempt++ { for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Clone request for retry (body needs to be re-readable) // Clone request for retry (body needs to be re-readable)
@@ -171,7 +184,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
resp, err := client.Do(reqCopy) resp, err := client.Do(reqCopy)
if err != nil { if err != nil {
lastErr = err lastErr = err
// Check for ISP blocking on network errors
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
// Don't retry if ISP blocking is detected - it won't help
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
}
if attempt < config.MaxRetries { if attempt < config.MaxRetries {
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
attempt+1, config.MaxRetries+1, err, delay)
time.Sleep(delay) time.Sleep(delay)
delay = calculateNextDelay(delay, config) delay = calculateNextDelay(delay, config)
} }
@@ -192,17 +214,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
} }
lastErr = fmt.Errorf("rate limited (429)") lastErr = fmt.Errorf("rate limited (429)")
if attempt < config.MaxRetries { if attempt < config.MaxRetries {
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
time.Sleep(delay) time.Sleep(delay)
delay = calculateNextDelay(delay, config) delay = calculateNextDelay(delay, config)
} }
continue continue
} }
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
}
for _, indicator := range ispBlockingIndicators {
if strings.Contains(bodyStr, indicator) {
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
LogError("HTTP", "Domain: %s", req.URL.Host)
LogError("HTTP", "Response contains: %s", indicator)
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
}
}
}
// Server errors (5xx) - retry // Server errors (5xx) - retry
if resp.StatusCode >= 500 { if resp.StatusCode >= 500 {
resp.Body.Close() resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode) lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
if attempt < config.MaxRetries { if attempt < config.MaxRetries {
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
time.Sleep(delay) time.Sleep(delay)
delay = calculateNextDelay(delay, config) delay = calculateNextDelay(delay, config)
} }
@@ -296,3 +344,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
} }
return msg return msg
} }
// ISPBlockingError represents an error caused by ISP blocking
type ISPBlockingError struct {
Domain string
Reason string
OriginalErr error
}
func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
// IsISPBlocking checks if an error is likely caused by ISP blocking
// Returns the ISPBlockingError if detected, nil otherwise
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
}
// Extract domain from URL
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
// Check for DNS resolution failure (common ISP blocking method)
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
return &ISPBlockingError{
Domain: domain,
Reason: "DNS resolution failed - domain may be blocked by ISP",
OriginalErr: err,
}
}
}
// Check for connection refused (ISP firewall blocking)
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
// Check for specific syscall errors
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
case syscall.ECONNREFUSED:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection refused - port may be blocked by ISP/firewall",
OriginalErr: err,
}
case syscall.ECONNRESET:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection reset - ISP may be intercepting traffic",
OriginalErr: err,
}
case syscall.ETIMEDOUT:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection timed out - ISP may be blocking access",
OriginalErr: err,
}
case syscall.ENETUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Network unreachable - ISP may be blocking route",
OriginalErr: err,
}
case syscall.EHOSTUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Host unreachable - ISP may be blocking destination",
OriginalErr: err,
}
}
}
}
}
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
Domain: domain,
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
OriginalErr: err,
}
}
// Check error message patterns for common ISP blocking indicators
blockingPatterns := []struct {
pattern string
reason string
}{
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
{"connection refused", "Connection refused - port may be blocked"},
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
{"certificate", "Certificate error - ISP may be using MITM proxy"},
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
}
for _, bp := range blockingPatterns {
if strings.Contains(errStr, bp.pattern) {
return &ISPBlockingError{
Domain: domain,
Reason: bp.reason,
OriginalErr: err,
}
}
}
return nil
}
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
LogError(tag, "Domain: %s", ispErr.Domain)
LogError(tag, "Reason: %s", ispErr.Reason)
LogError(tag, "Original error: %v", ispErr.OriginalErr)
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
return true
}
return false
}
// extractDomain extracts the domain from a URL string
func extractDomain(rawURL string) string {
if rawURL == "" {
return "unknown"
}
parsed, err := url.Parse(rawURL)
if err != nil {
// Try to extract domain manually
rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 {
return rawURL[:idx]
}
return rawURL
}
if parsed.Host != "" {
return parsed.Host
}
return "unknown"
}
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
}
if CheckAndLogISPBlocking(err, requestURL, tag) {
domain := extractDomain(requestURL)
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
}
return err
}
+203
View File
@@ -0,0 +1,203 @@
package gobackend
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// LogEntry represents a single log entry
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Tag string `json:"tag"`
Message string `json:"message"`
}
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
type LogBuffer struct {
entries []LogEntry
maxSize int
mu sync.RWMutex
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
}
var (
globalLogBuffer *LogBuffer
logBufferOnce sync.Once
)
// GetLogBuffer returns the singleton log buffer instance
func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, 500),
maxSize: 500,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
}
})
return globalLogBuffer
}
// SetLoggingEnabled enables or disables logging
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.loggingEnabled = enabled
}
// IsLoggingEnabled returns whether logging is enabled
func (lb *LogBuffer) IsLoggingEnabled() bool {
lb.mu.RLock()
defer lb.mu.RUnlock()
return lb.loggingEnabled
}
// Add adds a log entry to the buffer
func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock()
defer lb.mu.Unlock()
// Skip if logging is disabled (except for errors which are always logged)
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
return
}
entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"),
Level: level,
Tag: tag,
Message: message,
}
if len(lb.entries) >= lb.maxSize {
// Remove oldest entry
lb.entries = lb.entries[1:]
}
lb.entries = append(lb.entries, entry)
// Also print to logcat for debugging
fmt.Printf("[%s] %s\n", tag, message)
}
// GetAll returns all log entries as JSON
func (lb *LogBuffer) GetAll() string {
lb.mu.RLock()
defer lb.mu.RUnlock()
jsonBytes, _ := json.Marshal(lb.entries)
return string(jsonBytes)
}
// getSince returns log entries since the given index (internal use)
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
lb.mu.RLock()
defer lb.mu.RUnlock()
if index < 0 {
index = 0
}
if index >= len(lb.entries) {
return []LogEntry{}, len(lb.entries)
}
entries := lb.entries[index:]
return entries, len(lb.entries)
}
// Clear clears all log entries
func (lb *LogBuffer) Clear() {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.entries = lb.entries[:0]
}
// Count returns the number of log entries
func (lb *LogBuffer) Count() int {
lb.mu.RLock()
defer lb.mu.RUnlock()
return len(lb.entries)
}
// Helper functions for logging with different levels
func LogDebug(tag, format string, args ...interface{}) {
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
}
func LogInfo(tag, format string, args ...interface{}) {
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
}
func LogWarn(tag, format string, args ...interface{}) {
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
}
func LogError(tag, format string, args ...interface{}) {
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
}
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
// It parses the tag from the format string if it starts with [Tag]
func GoLog(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
message = strings.TrimSuffix(message, "\n")
// Extract tag from message if present (e.g., "[Tidal] message")
tag := "Go"
level := "INFO"
if strings.HasPrefix(message, "[") {
endBracket := strings.Index(message, "]")
if endBracket > 1 {
tag = message[1:endBracket]
message = strings.TrimSpace(message[endBracket+1:])
}
}
// Determine level from message content
msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
level = "ERROR"
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
level = "WARN"
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
level = "INFO"
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
level = "DEBUG"
}
GetLogBuffer().Add(level, tag, message)
}
// Exported functions for Flutter
// GetLogs returns all logs as JSON array
func GetLogs() string {
return GetLogBuffer().GetAll()
}
// GetLogsSince returns logs since the given index
// Returns JSON: {"logs": [...], "next_index": N}
func GetLogsSince(index int) string {
entries, nextIndex := GetLogBuffer().getSince(index)
logsJson, _ := json.Marshal(entries)
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
return result
}
// ClearLogs clears all logs
func ClearLogs() {
GetLogBuffer().Clear()
}
// GetLogCount returns the number of log entries
func GetLogCount() int {
return GetLogBuffer().Count()
}
// SetLoggingEnabled enables or disables logging from Flutter
func SetLoggingEnabled(enabled bool) {
GetLogBuffer().SetLoggingEnabled(enabled)
}
+85 -53
View File
@@ -58,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setComment(cmt, "DATE", metadata.Date) setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
if metadata.TotalTracks > 0 { if metadata.TotalTracks > 0 {
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
@@ -66,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
} }
} }
if metadata.DiscNumber > 0 { if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
} }
if metadata.ISRC != "" { if metadata.ISRC != "" {
setComment(cmt, "ISRC", metadata.ISRC) setComment(cmt, "ISRC", metadata.ISRC)
} }
if metadata.Description != "" { if metadata.Description != "" {
setComment(cmt, "DESCRIPTION", metadata.Description) setComment(cmt, "DESCRIPTION", metadata.Description)
} }
@@ -105,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
} }
} }
picture, err := flacpicture.NewFromImageData( picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover, flacpicture.PictureTypeFrontCover,
"Front Cover", "Front Cover",
@@ -162,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setComment(cmt, "DATE", metadata.Date) setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
if metadata.TotalTracks > 0 { if metadata.TotalTracks > 0 {
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
@@ -170,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
} }
} }
if metadata.DiscNumber > 0 { if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
} }
if metadata.ISRC != "" { if metadata.ISRC != "" {
setComment(cmt, "ISRC", metadata.ISRC) setComment(cmt, "ISRC", metadata.ISRC)
} }
if metadata.Description != "" { if metadata.Description != "" {
setComment(cmt, "DESCRIPTION", metadata.Description) setComment(cmt, "DESCRIPTION", metadata.Description)
} }
@@ -204,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
} }
} }
picture, err := flacpicture.NewFromImageData( picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover, flacpicture.PictureTypeFrontCover,
"Front Cover", "Front Cover",
@@ -257,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" { if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
} }
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
}
discNum := getComment(cmt, "DISCNUMBER") discNum := getComment(cmt, "DISCNUMBER")
if discNum != "" { if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
} }
// Also try DISC variant
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
}
// Try DATE variants
if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR")
}
break break
} }
@@ -291,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
} }
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments { for _, comment := range cmt.Comments {
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" { if len(comment) > len(key) {
return comment[len(key)+1:] // Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
}
} }
} }
return "" return ""
@@ -356,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) {
if err != nil { if err != nil {
continue continue
} }
// Try LYRICS tag first // Try LYRICS tag first
lyrics, err := cmt.Get("LYRICS") lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil return lyrics[0], nil
} }
// Fallback to UNSYNCEDLYRICS // Fallback to UNSYNCEDLYRICS
lyrics, err = cmt.Get("UNSYNCEDLYRICS") lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
@@ -376,8 +400,9 @@ func ExtractLyrics(filePath string) (string, error) {
// AudioQuality represents audio quality info from a FLAC file // AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
} }
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block // GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
@@ -395,7 +420,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
if _, err := file.Read(marker); err != nil { if _, err := file.Read(marker); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err) return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
} }
// Check if it's a FLAC file // Check if it's a FLAC file
if string(marker) == "fLaC" { if string(marker) == "fLaC" {
// Continue reading FLAC metadata // Continue reading FLAC metadata
@@ -422,12 +447,20 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
// Parse bits per sample (5 bits) // Parse bits per sample (5 bits)
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1 bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
int64(streamInfo[16])<<8 |
int64(streamInfo[17])
return AudioQuality{ return AudioQuality{
BitDepth: bitsPerSample, BitDepth: bitsPerSample,
SampleRate: sampleRate, SampleRate: sampleRate,
TotalSamples: totalSamples,
}, nil }, nil
} }
// Check if it's an M4A/MP4 file (starts with size + "ftyp") // Check if it's an M4A/MP4 file (starts with size + "ftyp")
// First 4 bytes are size, next 4 should be "ftyp" // First 4 bytes are size, next 4 should be "ftyp"
file.Seek(0, 0) // Reset to beginning file.Seek(0, 0) // Reset to beginning
@@ -435,17 +468,16 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
if _, err := file.Read(header8); err != nil { if _, err := file.Read(header8); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err) return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
} }
if string(header8[4:8]) == "ftyp" { if string(header8[4:8]) == "ftyp" {
// It's an M4A/MP4 file, use M4A quality reader // It's an M4A/MP4 file, use M4A quality reader
file.Close() // Close before calling GetM4AQuality which opens the file again file.Close() // Close before calling GetM4AQuality which opens the file again
return GetM4AQuality(filePath) return GetM4AQuality(filePath)
} }
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
} }
// ======================================== // ========================================
// M4A (MP4/AAC) Metadata Embedding // M4A (MP4/AAC) Metadata Embedding
// ======================================== // ========================================
@@ -468,16 +500,16 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
// Find udta atom inside moov, or create one // 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(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
udtaPos := findAtom(data, "udta", moovPos+8) udtaPos := findAtom(data, "udta", moovPos+8)
// Build new metadata atoms // Build new metadata atoms
metaAtom := buildMetaAtom(metadata, coverData) metaAtom := buildMetaAtom(metadata, coverData)
var newData []byte var newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize { if udtaPos >= 0 && udtaPos < moovPos+moovSize {
// udta exists, find meta inside it or replace // 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(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
metaPos := findAtom(data, "meta", udtaPos+8) metaPos := findAtom(data, "meta", udtaPos+8)
if metaPos >= 0 && metaPos < udtaPos+udtaSize { if metaPos >= 0 && metaPos < udtaPos+udtaSize {
// Replace existing meta atom // Replace existing meta atom
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3]) metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
@@ -495,7 +527,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
newUdta[3] = byte(newUdtaSize) newUdta[3] = byte(newUdtaSize)
newUdta = append(newUdta, []byte("udta")...) newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, newUdtaContent...) newUdta = append(newUdta, newUdtaContent...)
newData = append(newData, data[:udtaPos]...) newData = append(newData, data[:udtaPos]...)
newData = append(newData, newUdta...) newData = append(newData, newUdta...)
newData = append(newData, data[udtaPos+udtaSize:]...) newData = append(newData, data[udtaPos+udtaSize:]...)
@@ -511,14 +543,14 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
newUdta[3] = byte(udtaSize) newUdta[3] = byte(udtaSize)
newUdta = append(newUdta, []byte("udta")...) newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, udtaContent...) newUdta = append(newUdta, udtaContent...)
// Insert udta at end of moov // Insert udta at end of moov
insertPos := moovPos + moovSize insertPos := moovPos + moovSize
newData = append(newData, data[:insertPos]...) newData = append(newData, data[:insertPos]...)
newData = append(newData, newUdta...) newData = append(newData, newUdta...)
newData = append(newData, data[insertPos:]...) newData = append(newData, data[insertPos:]...)
} }
// Update moov size // Update moov size
newMoovSize := moovSize + len(newData) - len(data) newMoovSize := moovSize + len(newData) - len(data)
newData[moovPos] = byte(newMoovSize >> 24) newData[moovPos] = byte(newMoovSize >> 24)
@@ -555,52 +587,52 @@ func findAtom(data []byte, name string, offset int) int {
func buildMetaAtom(metadata Metadata, coverData []byte) []byte { func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
// Build ilst content // Build ilst content
var ilst []byte var ilst []byte
// ©nam - Title // ©nam - Title
if metadata.Title != "" { if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...) ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
} }
// ©ART - Artist // ©ART - Artist
if metadata.Artist != "" { if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...) ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
} }
// ©alb - Album // ©alb - Album
if metadata.Album != "" { if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...) ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
} }
// aART - Album Artist // aART - Album Artist
if metadata.AlbumArtist != "" { if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...) ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
} }
// ©day - Year/Date // ©day - Year/Date
if metadata.Date != "" { if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...) ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
} }
// trkn - Track Number // trkn - Track Number
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...) ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
} }
// disk - Disc Number // disk - Disc Number
if metadata.DiscNumber > 0 { if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...) ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
} }
// ©lyr - Lyrics // ©lyr - Lyrics
if metadata.Lyrics != "" { if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...) ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
} }
// covr - Cover Art // covr - Cover Art
if len(coverData) > 0 { if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...) ilst = append(ilst, buildCoverAtom(coverData)...)
} }
// Build ilst atom // Build ilst atom
ilstSize := 8 + len(ilst) ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4) ilstAtom := make([]byte, 4)
@@ -610,7 +642,7 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
ilstAtom[3] = byte(ilstSize) ilstAtom[3] = byte(ilstSize)
ilstAtom = append(ilstAtom, []byte("ilst")...) ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...) ilstAtom = append(ilstAtom, ilst...)
// Build hdlr atom (required for meta) // Build hdlr atom (required for meta)
hdlr := []byte{ hdlr := []byte{
0, 0, 0, 33, // size = 33 0, 0, 0, 33, // size = 33
@@ -623,11 +655,11 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
0, 0, 0, 0, // component flags mask 0, 0, 0, 0, // component flags mask
0, // null terminator 0, // null terminator
} }
// Build meta atom // Build meta atom
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...) metaContent = append(metaContent, ilstAtom...)
metaSize := 8 + len(metaContent) metaSize := 8 + len(metaContent)
metaAtom := make([]byte, 4) metaAtom := make([]byte, 4)
metaAtom[0] = byte(metaSize >> 24) metaAtom[0] = byte(metaSize >> 24)
@@ -636,14 +668,14 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
metaAtom[3] = byte(metaSize) metaAtom[3] = byte(metaSize)
metaAtom = append(metaAtom, []byte("meta")...) metaAtom = append(metaAtom, []byte("meta")...)
metaAtom = append(metaAtom, metaContent...) metaAtom = append(metaAtom, metaContent...)
return metaAtom return metaAtom
} }
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.) // buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
func buildTextAtom(name, value string) []byte { func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value) valueBytes := []byte(value)
// data atom // data atom
dataSize := 16 + len(valueBytes) dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4) dataAtom := make([]byte, 4)
@@ -655,7 +687,7 @@ func buildTextAtom(name, value string) []byte {
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8 dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...) dataAtom = append(dataAtom, valueBytes...)
// container atom // container atom
atomSize := 8 + len(dataAtom) atomSize := 8 + len(dataAtom)
atom := make([]byte, 4) atom := make([]byte, 4)
@@ -665,7 +697,7 @@ func buildTextAtom(name, value string) []byte {
atom[3] = byte(atomSize) atom[3] = byte(atomSize)
atom = append(atom, []byte(name)...) atom = append(atom, []byte(name)...)
atom = append(atom, dataAtom...) atom = append(atom, dataAtom...)
return atom return atom
} }
@@ -682,7 +714,7 @@ func buildTrackNumberAtom(track, total int) []byte {
byte(total >> 8), byte(total), // total tracks byte(total >> 8), byte(total), // total tracks
0, 0, // padding 0, 0, // padding
} }
// trkn atom // trkn atom
atomSize := 8 + len(dataAtom) atomSize := 8 + len(dataAtom)
atom := make([]byte, 4) atom := make([]byte, 4)
@@ -692,7 +724,7 @@ func buildTrackNumberAtom(track, total int) []byte {
atom[3] = byte(atomSize) atom[3] = byte(atomSize)
atom = append(atom, []byte("trkn")...) atom = append(atom, []byte("trkn")...)
atom = append(atom, dataAtom...) atom = append(atom, dataAtom...)
return atom return atom
} }
@@ -708,7 +740,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
byte(disc >> 8), byte(disc), // disc number byte(disc >> 8), byte(disc), // disc number
byte(total >> 8), byte(total), // total discs byte(total >> 8), byte(total), // total discs
} }
// disk atom // disk atom
atomSize := 8 + len(dataAtom) atomSize := 8 + len(dataAtom)
atom := make([]byte, 4) atom := make([]byte, 4)
@@ -718,7 +750,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
atom[3] = byte(atomSize) atom[3] = byte(atomSize)
atom = append(atom, []byte("disk")...) atom = append(atom, []byte("disk")...)
atom = append(atom, dataAtom...) atom = append(atom, dataAtom...)
return atom return atom
} }
@@ -729,7 +761,7 @@ func buildCoverAtom(coverData []byte) []byte {
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' { if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG imageType = 14 // PNG
} }
// data atom // data atom
dataSize := 16 + len(coverData) dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4) dataAtom := make([]byte, 4)
@@ -741,7 +773,7 @@ func buildCoverAtom(coverData []byte) []byte {
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, coverData...) dataAtom = append(dataAtom, coverData...)
// covr atom // covr atom
atomSize := 8 + len(dataAtom) atomSize := 8 + len(dataAtom)
atom := make([]byte, 4) atom := make([]byte, 4)
@@ -751,7 +783,7 @@ func buildCoverAtom(coverData []byte) []byte {
atom[3] = byte(atomSize) atom[3] = byte(atomSize)
atom = append(atom, []byte("covr")...) atom = append(atom, []byte("covr")...)
atom = append(atom, dataAtom...) atom = append(atom, dataAtom...)
return atom return atom
} }
+285 -57
View File
@@ -52,49 +52,224 @@ type QobuzTrack struct {
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match // Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other // Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
// Check first artist (before comma or feat) // Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0] expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0] expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0] expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst) expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0] foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0] foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0] foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst) foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst { if expectedFirst == foundFirst {
return true return true
} }
// Check if first artist is contained in the other // Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true return true
} }
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// assume they're the same artist with different transliteration // Don't treat Latin Extended (Polish, French, etc.) as different script
expectedASCII := qobuzIsASCIIString(expectedArtist) expectedLatin := qobuzIsLatinScript(expectedArtist)
foundASCII := qobuzIsASCIIString(foundArtist) foundLatin := qobuzIsLatinScript(foundArtist)
if expectedASCII != foundASCII { if expectedLatin != foundLatin {
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true return true
} }
return false return false
} }
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
cleanExpected := qobuzCleanTitle(normExpected)
cleanFound := qobuzCleanTitle(normFound)
if cleanExpected == cleanFound {
return true
}
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true
}
}
// Extract core title (before any parentheses/brackets)
coreExpected := qobuzExtractCoreTitle(normExpected)
coreFound := qobuzExtractCoreTitle(normFound)
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
return true
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin {
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
return false
}
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
func qobuzExtractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ")
cutIdx := len(title)
if parenIdx > 0 && parenIdx < cutIdx {
cutIdx = parenIdx
}
if bracketIdx > 0 && bracketIdx < cutIdx {
cutIdx = bracketIdx
}
if dashIdx > 0 && dashIdx < cutIdx {
cutIdx = dashIdx
}
return strings.TrimSpace(title[:cutIdx])
}
// qobuzCleanTitle removes common suffixes from track titles for comparison
func qobuzCleanTitle(title string) string {
cleaned := title
// Remove content in parentheses/brackets that are version indicators
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo",
}
// Remove parenthetical content if it contains version indicators
for {
startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")")
if startParen >= 0 && endParen > startParen {
content := strings.ToLower(cleaned[startParen+1 : endParen])
isVersionIndicator := false
for _, pattern := range versionPatterns {
if strings.Contains(content, pattern) {
isVersionIndicator = true
break
}
}
if isVersionIndicator {
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
continue
}
}
break
}
// Same for brackets
for {
startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]")
if startBracket >= 0 && endBracket > startBracket {
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
isVersionIndicator := false
for _, pattern := range versionPatterns {
if strings.Contains(content, pattern) {
isVersionIndicator = true
break
}
}
if isVersionIndicator {
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
continue
}
}
break
}
// Remove trailing " - version" patterns
dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix",
}
for _, pattern := range dashPatterns {
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
cleaned = cleaned[:len(cleaned)-len(pattern)]
}
}
// Remove multiple spaces
for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ")
}
return strings.TrimSpace(cleaned)
}
// qobuzIsLatinScript checks if a string is primarily Latin script
// Returns true for ASCII and Latin Extended characters (European languages)
// Returns false for CJK, Arabic, Cyrillic, etc.
func qobuzIsLatinScript(s string) bool {
for _, r := range s {
// Skip common punctuation and numbers
if r < 128 {
continue
}
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
// Latin Extended-B: U+0180 to U+024F
// Latin Extended Additional: U+1E00 to U+1EFF
// Latin Extended-C/D/E: various ranges
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
continue
}
// CJK ranges - definitely different script
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
(r >= 0x3040 && r <= 0x309F) || // Hiragana
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
(r >= 0x0600 && r <= 0x06FF) || // Arabic
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
return false
}
}
return true
}
// qobuzIsASCIIString checks if a string contains only ASCII characters // qobuzIsASCIIString checks if a string contains only ASCII characters
func qobuzIsASCIIString(s string) bool { func qobuzIsASCIIString(s string) bool {
for _, r := range s { for _, r := range s {
@@ -132,8 +307,8 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go) // Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz // Primary: dab.yeet.su, Fallback: dabmusic.xyz
encodedAPIs := []string{ encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
} }
var apis []string var apis []string
@@ -194,6 +369,8 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification // SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification) // expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -221,6 +398,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return nil, err return nil, err
} }
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
// Find ISRC matches // Find ISRC matches
var isrcMatches []*QobuzTrack var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
@@ -229,6 +408,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
} }
} }
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 { if len(isrcMatches) > 0 {
// Verify duration if provided // Verify duration if provided
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
@@ -238,27 +419,27 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
// Allow 30 seconds tolerance // Allow 10 seconds tolerance
if durationDiff <= 30 { if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track) durationVerifiedMatches = append(durationVerifiedMatches, track)
} }
} }
if len(durationVerifiedMatches) > 0 { if len(durationVerifiedMatches) > 0 {
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration) durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil return durationVerifiedMatches[0], nil
} }
// ISRC matches but duration doesn't // ISRC matches but duration doesn't
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration) isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration) expectedDurationSec, isrcMatches[0].Duration)
} }
// No duration to verify, return first match // No duration to verify, return first match
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
} }
@@ -281,6 +462,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
// SearchTrackByMetadataWithDuration searches for a track with duration verification // SearchTrackByMetadataWithDuration searches for a track with duration verification
// Now includes romaji conversion for Japanese text (same as Tidal) // Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
@@ -312,7 +494,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQueryQobuz(queries, romajiQuery) { if !containsQueryQobuz(queries, romajiQuery) {
queries = append(queries, romajiQuery) queries = append(queries, romajiQuery)
fmt.Printf("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery) GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
} }
} }
@@ -342,7 +524,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
searchedQueries[cleanQuery] = true searchedQueries[cleanQuery] = true
fmt.Printf("[Qobuz] Searching for: %s\n", cleanQuery) GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
@@ -353,7 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
resp, err := DoRequestWithUserAgent(q.client, req) resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil { if err != nil {
fmt.Printf("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
continue continue
} }
@@ -374,7 +556,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
resp.Body.Close() resp.Body.Close()
if len(result.Tracks.Items) > 0 { if len(result.Tracks.Items) > 0 {
fmt.Printf("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery) GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
allTracks = append(allTracks, result.Tracks.Items...) allTracks = append(allTracks, result.Tracks.Items...)
} }
} }
@@ -383,16 +565,35 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
} }
// Filter by title match first (NEW - like Tidal)
var titleMatches []*QobuzTrack
for i := range allTracks {
track := &allTracks[i]
if qobuzTitlesMatch(trackName, track.Title) {
titleMatches = append(titleMatches, track)
}
}
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
// If no title matches, log warning but continue with all tracks
tracksToCheck := titleMatches
if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
for i := range allTracks {
tracksToCheck = append(tracksToCheck, &allTracks[i])
}
}
// If duration verification is requested // If duration verification is requested
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack var durationMatches []*QobuzTrack
for i := range allTracks { for _, track := range tracksToCheck {
track := &allTracks[i]
durationDiff := track.Duration - expectedDurationSec durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
if durationDiff <= 30 { if durationDiff <= 10 {
durationMatches = append(durationMatches, track) durationMatches = append(durationMatches, track)
} }
} }
@@ -401,24 +602,36 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
// Return best quality among duration matches // Return best quality among duration matches
for _, track := range durationMatches { for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
durationMatches[0].Title, durationMatches[0].Performer.Name)
return durationMatches[0], nil return durationMatches[0], nil
} }
// No duration match found // No duration match found
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec) return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
} }
// No duration verification, return best quality // No duration verification, return best quality from title matches
for i := range allTracks { for _, track := range tracksToCheck {
track := &allTracks[i]
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
return &allTracks[0], nil
if len(tracksToCheck) > 0 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
return tracksToCheck[0], nil
}
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
} }
// getQobuzDownloadURLSequential requests download URL from APIs sequentially // getQobuzDownloadURLSequential requests download URL from APIs sequentially
@@ -437,7 +650,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
// The apiURL already includes the path, just append trackID and quality // The apiURL already includes the path, just append trackID and quality
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality) reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
fmt.Printf("[Qobuz] Trying: %s\n", reqURL) GoLog("[Qobuz] Trying: %s\n", reqURL)
req, err := http.NewRequest("GET", reqURL, nil) req, err := http.NewRequest("GET", reqURL, nil)
if err != nil { if err != nil {
@@ -482,7 +695,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
} }
if result.URL != "" { if result.URL != "" {
fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL) GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
return apiURL, result.URL, nil return apiURL, result.URL, nil
} }
@@ -592,6 +805,7 @@ type QobuzDownloadResult struct {
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string
} }
// downloadFromQobuz downloads a track using the request parameters // downloadFromQobuz downloads a track using the request parameters
@@ -612,11 +826,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// OPTIMIZATION: Check cache first for track ID // OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" { if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID // For Qobuz we need to search again to get full track info, but we can use the ID
track, err = downloader.SearchTrackByISRC(req.ISRC) track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil { if err != nil {
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err) GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
track = nil track = nil
} }
} }
@@ -624,21 +838,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 1: Search by ISRC with duration verification // Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist // Verify artist AND title
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if track != nil {
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
req.ArtistName, track.Performer.Name) GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
track = nil req.ArtistName, track.Performer.Name)
track = nil
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title)
track = nil
}
} }
} }
// Strategy 2: Search by metadata with duration verification // Strategy 2: Search by metadata with duration verification (includes title verification)
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist // Verify artist (title already verified in SearchTrackByMetadataWithDuration)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name) req.ArtistName, track.Performer.Name)
track = nil track = nil
} }
@@ -653,7 +874,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
// Log match found and cache the track ID // Log match found and cache the track ID
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID) GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
} }
@@ -687,12 +908,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
case "HI_RES_LOSSLESS": case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz qobuzQuality = "27" // 24-bit 192kHz
} }
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get actual quality from track metadata // Get actual quality from track metadata
actualBitDepth := track.MaximumBitDepth actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
// Get download URL using parallel API requests // Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
@@ -731,11 +952,17 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
// Embed metadata using parallel-fetched cover data // Embed metadata using parallel-fetched cover data
// Use metadata from the actual Qobuz track found (more accurate than request) // Use metadata from the actual Qobuz track found (more accurate than request) but prefer
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
albumName := track.Album.Title
if req.AlbumName != "" {
albumName = req.AlbumName
}
metadata := Metadata{ metadata := Metadata{
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: track.Album.Title, Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate, Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
@@ -748,7 +975,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
var coverData []byte var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil { if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData coverData = parallelResult.CoverData
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} }
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
@@ -757,9 +984,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Embed lyrics from parallel fetch // Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else { } else {
fmt.Println("[Qobuz] Lyrics embedded successfully") fmt.Println("[Qobuz] Lyrics embedded successfully")
} }
@@ -780,5 +1007,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations DiscNumber: req.DiscNumber, // Qobuz track struct limitations
ISRC: track.ISRC,
}, nil }, nil
} }
+488 -124
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -181,6 +181,13 @@ import Gobackend // Import Go framework
GobackendCleanupConnections() GobackendCleanupConnections()
return nil return nil
case "readFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendReadFileMetadata(filePath, &error)
if let error = error { throw error }
return response
case "searchDeezerAll": case "searchDeezerAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
@@ -242,6 +249,38 @@ import Gobackend // Import Go framework
GobackendClearTrackCache() GobackendClearTrackCache()
return nil return nil
case "setSpotifyCredentials":
let args = call.arguments as! [String: Any]
let clientId = args["client_id"] as! String
let clientSecret = args["client_secret"] as! String
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
return nil
// Log methods
case "getLogs":
let response = GobackendGetLogs()
return response
case "getLogsSince":
let args = call.arguments as! [String: Any]
let index = args["index"] as? Int ?? 0
let response = GobackendGetLogsSince(Int(index))
return response
case "clearLogs":
GobackendClearLogs()
return nil
case "getLogCount":
let response = GobackendGetLogCount()
return response
case "setLoggingEnabled":
let args = call.arguments as! [String: Any]
let enabled = args["enabled"] as? Bool ?? false
GobackendSetLoggingEnabled(enabled)
return nil
default: default:
throw NSError( throw NSError(
domain: "SpotiFLAC", domain: "SpotiFLAC",
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '2.1.7'; static const String version = '2.2.9';
static const String buildNumber = '45'; static const String buildNumber = '51';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+3
View File
@@ -19,6 +19,7 @@ enum DownloadErrorType {
notFound, // Track not found on any service notFound, // Track not found on any service
rateLimit, // Rate limited by service rateLimit, // Rate limited by service
network, // Network/connection error network, // Network/connection error
permission, // File/folder permission error
} }
@JsonSerializable() @JsonSerializable()
@@ -88,6 +89,8 @@ class DownloadItem {
return 'Rate limit reached, try again later'; return 'Rate limit reached, try again later';
case DownloadErrorType.network: case DownloadErrorType.network:
return 'Connection failed, check your internet'; return 'Connection failed, check your internet';
case DownloadErrorType.permission:
return 'Cannot write to folder, check storage permission';
default: default:
return error ?? 'An error occurred'; return error ?? 'An error occurred';
} }
+1
View File
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.notFound: 'notFound', DownloadErrorType.notFound: 'notFound',
DownloadErrorType.rateLimit: 'rateLimit', DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network', DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
}; };
+9 -1
View File
@@ -18,14 +18,16 @@ class AppSettings {
final bool hasSearchedBefore; // Hide helper text after first search final bool hasSearchedBefore; // Hide helper text after first search
final String folderOrganization; // none, artist, album, artist_album final String folderOrganization; // none, artist, album, artist_album
final String historyViewMode; // list, grid final String historyViewMode; // list, grid
final String historyFilterMode; // all, albums, singles
final bool askQualityBeforeDownload; // Show quality picker before each download final bool askQualityBeforeDownload; // Show quality picker before each download
final String spotifyClientId; // Custom Spotify client ID (empty = use default) final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
final String metadataSource; // spotify, deezer - source for search and metadata final String metadataSource; // spotify, deezer - source for search and metadata
final bool enableLogging; // Enable detailed logging for debugging
const AppSettings({ const AppSettings({
this.defaultService = 'qobuz', this.defaultService = 'tidal',
this.audioQuality = 'LOSSLESS', this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}', this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '', this.downloadDirectory = '',
@@ -39,11 +41,13 @@ class AppSettings {
this.hasSearchedBefore = false, // Default: show helper text this.hasSearchedBefore = false, // Default: show helper text
this.folderOrganization = 'none', // Default: no folder organization this.folderOrganization = 'none', // Default: no folder organization
this.historyViewMode = 'grid', // Default: grid view this.historyViewMode = 'grid', // Default: grid view
this.historyFilterMode = 'all', // Default: show all
this.askQualityBeforeDownload = true, // Default: ask quality before download this.askQualityBeforeDownload = true, // Default: ask quality before download
this.spotifyClientId = '', // Default: use built-in credentials this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials this.spotifyClientSecret = '', // Default: use built-in credentials
this.useCustomSpotifyCredentials = true, // Default: use custom if set this.useCustomSpotifyCredentials = true, // Default: use custom if set
this.metadataSource = 'deezer', // Default: Deezer (no rate limit) this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
this.enableLogging = false, // Default: disabled for performance
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -61,11 +65,13 @@ class AppSettings {
bool? hasSearchedBefore, bool? hasSearchedBefore,
String? folderOrganization, String? folderOrganization,
String? historyViewMode, String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
String? spotifyClientId, String? spotifyClientId,
String? spotifyClientSecret, String? spotifyClientSecret,
bool? useCustomSpotifyCredentials, bool? useCustomSpotifyCredentials,
String? metadataSource, String? metadataSource,
bool? enableLogging,
}) { }) {
return AppSettings( return AppSettings(
defaultService: defaultService ?? this.defaultService, defaultService: defaultService ?? this.defaultService,
@@ -82,11 +88,13 @@ class AppSettings {
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
historyViewMode: historyViewMode ?? this.historyViewMode, historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId, spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource, metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
); );
} }
+4
View File
@@ -21,12 +21,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
spotifyClientId: json['spotifyClientId'] as String? ?? '', spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials: useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true, json['useCustomSpotifyCredentials'] as bool? ?? true,
metadataSource: json['metadataSource'] as String? ?? 'deezer', metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
); );
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) => Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -45,9 +47,11 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'hasSearchedBefore': instance.hasSearchedBefore, 'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'spotifyClientId': instance.spotifyClientId, 'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret, 'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
'metadataSource': instance.metadataSource, 'metadataSource': instance.metadataSource,
'enableLogging': instance.enableLogging,
}; };
File diff suppressed because it is too large Load Diff
+16
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings'; const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
@@ -26,6 +27,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
// Apply Spotify credentials to Go backend on load // Apply Spotify credentials to Go backend on load
_applySpotifyCredentials(); _applySpotifyCredentials();
// Sync logging state
LogBuffer.loggingEnabled = state.enableLogging;
} }
} }
@@ -144,6 +148,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setHistoryFilterMode(String mode) {
state = state.copyWith(historyFilterMode: mode);
_saveSettings();
}
void setAskQualityBeforeDownload(bool enabled) { void setAskQualityBeforeDownload(bool enabled) {
state = state.copyWith(askQualityBeforeDownload: enabled); state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings(); _saveSettings();
@@ -187,6 +196,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(metadataSource: source); state = state.copyWith(metadataSource: source);
_saveSettings(); _saveSettings();
} }
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
// Sync logging state to LogBuffer
LogBuffer.loggingEnabled = enabled;
}
} }
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>( final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+66 -22
View File
@@ -1,6 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('TrackProvider');
class TrackState { class TrackState {
final List<Track> tracks; final List<Track> tracks;
@@ -210,28 +213,60 @@ class TrackNotifier extends Notifier<TrackState> {
// Use Deezer or Spotify based on settings // Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer'; final source = metadataSource ?? 'deezer';
// Debug log to show which source is being used _log.i('Search started: source=$source, query="$query"');
// ignore: avoid_print
print('[Search] Using metadata source: $source for query: "$query"');
Map<String, dynamic> results; Map<String, dynamic> results;
if (source == 'deezer') { if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5); results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
// ignore: avoid_print _log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
} else { } else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
// ignore: avoid_print _log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
} }
if (!_isRequestValid(requestId)) return; // Request cancelled if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
return;
}
final trackList = results['tracks'] as List<dynamic>? ?? []; final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? []; final artistList = results['artists'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList(); _log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
// Parse tracks with error handling per item
final tracks = <Track>[];
for (int i = 0; i < trackList.length; i++) {
final t = trackList[i];
try {
if (t is Map<String, dynamic>) {
tracks.add(_parseSearchTrack(t));
} else {
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
}
} catch (e) {
_log.e('Failed to parse track[$i]: $e', e);
}
}
// Parse artists with error handling per item
final artists = <SearchArtist>[];
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
try {
if (a is Map<String, dynamic>) {
artists.add(_parseSearchArtist(a));
} else {
_log.w('Artist[$i] is not a Map: ${a.runtimeType}');
}
} catch (e) {
_log.e('Failed to parse artist[$i]: $e', e);
}
}
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
@@ -239,9 +274,9 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
); );
} catch (e) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; // Request cancelled if (!_isRequestValid(requestId)) return;
// Preserve hasSearchText on error so user stays on search screen _log.e('Search failed: $e', e, stackTrace);
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
} }
} }
@@ -310,18 +345,27 @@ class TrackNotifier extends Notifier<TrackState> {
} }
Track _parseSearchTrack(Map<String, dynamic> data) { Track _parseSearchTrack(Map<String, dynamic> data) {
// Handle duration_ms which might be int or double
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track( return Track(
id: data['spotify_id'] as String? ?? '', id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: data['name'] as String? ?? '', name: (data['name'] ?? '').toString(),
artistName: data['artists'] as String? ?? '', artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: data['album_name'] as String? ?? '', albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist']?.toString(),
coverUrl: data['images'] as String?, coverUrl: data['images']?.toString(),
isrc: data['isrc'] as String?, isrc: data['isrc']?.toString(),
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date']?.toString(),
); );
} }
+24 -4
View File
@@ -159,6 +159,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
// Validate image URL - must be non-null, non-empty, and have a valid host
final hasValidImage = widget.coverUrl != null &&
widget.coverUrl!.isNotEmpty &&
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
return SliverAppBar( return SliverAppBar(
expandedHeight: 280, expandedHeight: 280,
pinned: true, pinned: true,
@@ -169,8 +174,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (widget.coverUrl != null) if (hasValidImage)
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600), CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -192,8 +204,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))], boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
), ),
child: ClipOval( child: ClipOval(
child: widget.coverUrl != null child: hasValidImage
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) ? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 280,
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
),
)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)), : Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
), ),
), ),
+573
View File
@@ -0,0 +1,573 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
/// Screen to display downloaded tracks from a specific album
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
final String? coverUrl;
const DownloadedAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverUrl,
});
@override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
final albumKey = '${widget.albumName}|${widget.artistName}';
return itemKey == albumKey;
}).toList()
..sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<DownloadHistoryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
historyNotifier.removeFromHistory(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
);
}
}
}
Future<void> _openFile(String filePath) async {
try {
await OpenFilex.open(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
));
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
// Watch history and get tracks for this album (reactive!)
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final tracks = _getAlbumTracks(allHistoryItems);
// Auto-pop if album has less than 2 tracks (no longer an "album")
if (tracks.length < 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context);
});
return const SizedBox.shrink();
}
// Clean up selected IDs that no longer exist
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
],
),
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
),
],
),
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
),
);
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
if (tracks.isEmpty) return null;
final firstQuality = tracks.first.quality;
if (firstQuality == null) return null;
for (final track in tracks) {
if (track.quality != firstQuality) return null;
}
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: null,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
),
),
);
}
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, -4),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
Row(
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$selectedCount selected',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? 'All tracks selected' : 'Tap tracks to select',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
TextButton.icon(
onPressed: () {
if (allSelected) {
_exitSelectionMode();
} else {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? 'Deselect' : 'Select All'),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
],
),
),
),
);
}
}
+156 -17
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -266,6 +267,104 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
); );
} }
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
// Show loading dialog with progress
int currentProgress = 0;
int totalTracks = 0;
// Use StatefulBuilder to update dialog content
final dialogContext = context;
bool dialogShown = false;
StateSetter? setDialogState;
void showProgressDialog() {
if (dialogShown) return;
dialogShown = true;
showDialog(
context: dialogContext,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
setDialogState = setState;
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
totalTracks > 0
? 'Fetching metadata... $currentProgress/$totalTracks'
: 'Reading CSV...',
),
],
),
);
},
),
);
}
final tracks = await CsvImportService.pickAndParseCsv(
onProgress: (current, total) {
currentProgress = current;
totalTracks = total;
if (!dialogShown && total > 0) {
showProgressDialog();
}
setDialogState?.call(() {});
},
);
// Close progress dialog
if (dialogShown && mounted) {
Navigator.of(dialogContext).pop();
}
if (tracks.isNotEmpty) {
final settings = ref.read(settingsProvider);
// Optionally show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Import Playlist'),
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Import'),
),
],
),
);
if (confirmed == true) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${tracks.length} tracks to queue'),
action: SnackBarAction(
label: 'View Queue',
onPressed: () {
// Navigate to queue tab (handled by main_shell index)
// We don't have direct access to set index here easily without provider
},
),
),
);
}
}
} else {
// Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty)
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@@ -289,6 +388,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading; final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
final screenHeight = MediaQuery.of(context).size.height; final screenHeight = MediaQuery.of(context).size.height;
final topPadding = MediaQuery.of(context).padding.top;
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
return Scaffold( return Scaffold(
@@ -297,24 +397,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
slivers: [ slivers: [
// App Bar - always present // App Bar - always present
SliverAppBar( SliverAppBar(
expandedHeight: 130, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar( flexibleSpace: LayoutBuilder(
expandedTitleScale: 1.3, builder: (context, constraints) {
titlePadding: const EdgeInsets.only(left: 24, bottom: 16), final maxHeight = 120 + topPadding;
title: Text( final minHeight = kToolbarHeight + topPadding;
'Home', final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
style: TextStyle(
fontSize: 28, return FlexibleSpaceBar(
fontWeight: FontWeight.bold, expandedTitleScale: 1.0,
color: colorScheme.onSurface, titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
), title: Text(
), 'Home',
style: TextStyle(
fontSize: 20 + (14 * expandRatio), // 20 -> 34
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
), ),
), ),
@@ -329,12 +437,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
children: [ children: [
SizedBox(height: screenHeight * 0.06), SizedBox(height: screenHeight * 0.06),
Container( Container(
padding: const EdgeInsets.all(24), width: 96,
height: 96,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3), color: colorScheme.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary, // Tint with onPrimary color
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
// Fallback to original logo if transparent one is missing
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
fit: BoxFit.cover,
),
),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -651,6 +774,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) { Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
// Validate image URL - must be non-null, non-empty, and have a valid host
final hasValidImage = artist.imageUrl != null &&
artist.imageUrl!.isNotEmpty &&
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
return GestureDetector( return GestureDetector(
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl), onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
child: Container( child: Container(
@@ -666,12 +794,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
), ),
child: ClipOval( child: ClipOval(
child: artist.imageUrl != null child: hasValidImage
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: artist.imageUrl!, imageUrl: artist.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 200, memCacheWidth: 200,
memCacheHeight: 200, memCacheHeight: 200,
errorWidget: (context, url, error) => Icon(
Icons.person,
color: colorScheme.onSurfaceVariant,
size: 44,
),
) )
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44), : Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
), ),
@@ -736,12 +869,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
onPressed: _clearAndRefresh, onPressed: _clearAndRefresh,
tooltip: 'Clear', tooltip: 'Clear',
) )
else else ...[
IconButton(
icon: const Icon(Icons.file_upload_outlined),
onPressed: () => _importCsv(context, ref),
tooltip: 'Import CSV',
),
IconButton( IconButton(
icon: const Icon(Icons.paste), icon: const Icon(Icons.paste),
onPressed: _pasteFromClipboard, onPressed: _pasteFromClipboard,
tooltip: 'Paste', tooltip: 'Paste',
), ),
],
], ],
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+3
View File
@@ -62,6 +62,9 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
void _handleSharedUrl(String url) { void _handleSharedUrl(String url) {
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
Navigator.of(context).popUntil((route) => route.isFirst);
// Navigate to Home tab // Navigate to Home tab
if (_currentIndex != 0) { if (_currentIndex != 0) {
_onNavTap(0); _onNavTap(0);
+979 -390
View File
File diff suppressed because it is too large Load Diff
+93 -20
View File
@@ -109,14 +109,14 @@ class AboutPage extends StatelessWidget {
githubUsername: 'sachinsenal0x64', githubUsername: 'sachinsenal0x64',
showDivider: true, showDivider: true,
), ),
SettingsItem( _AboutSettingsItem(
icon: Icons.cloud_outlined, icon: Icons.cloud_outlined,
title: 'DoubleDouble', title: 'DoubleDouble',
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!', subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
onTap: () => _launchUrl('https://doubledouble.top'), onTap: () => _launchUrl('https://doubledouble.top'),
showDivider: true, showDivider: true,
), ),
SettingsItem( _AboutSettingsItem(
icon: Icons.music_note_outlined, icon: Icons.music_note_outlined,
title: 'DAB Music', title: 'DAB Music',
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!', subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
@@ -249,30 +249,26 @@ class _AppHeaderCard extends StatelessWidget {
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
children: [ children: [
// App logo
// App logo // App logo
Container( Container(
width: 88, width: 88,
height: 88, height: 88,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer, color: colorScheme.primary,
borderRadius: BorderRadius.circular(24), shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.2),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
), ),
child: ClipRRect( child: Image.asset(
borderRadius: BorderRadius.circular(24), 'assets/images/logo-transparant.png',
child: Image.asset( color: colorScheme.onPrimary, // Tint with onPrimary color
'assets/images/logo.png', fit: BoxFit.contain,
fit: BoxFit.cover, errorBuilder: (_, _, _) => ClipRRect(
errorBuilder: (_, _, _) => Icon( borderRadius: BorderRadius.circular(24),
Icons.music_note, child: Image.asset(
size: 48, 'assets/images/logo.png',
color: colorScheme.onPrimaryContainer, width: 88,
height: 88,
fit: BoxFit.cover,
), ),
), ),
), ),
@@ -417,3 +413,80 @@ class _ContributorItem extends StatelessWidget {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView); await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
} }
} }
/// Settings item with 40x40 icon area to align with contributor avatars
class _AboutSettingsItem extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback? onTap;
final bool showDivider;
const _AboutSettingsItem({
required this.icon,
required this.title,
this.subtitle,
this.onTap,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// Icon with 40x40 size to match avatar
SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 76, // 20 + 40 + 16 = 76 (same as contributor item)
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
+482 -115
View File
@@ -27,68 +27,108 @@ class AppearanceSettingsPage extends ConsumerWidget {
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), leading: IconButton(
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
topPadding: topPadding,
),
),
// Preview Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: _ThemePreviewCard(),
),
),
// Color section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Color'),
), ),
// Theme section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
SettingsSwitchItem( SettingsSwitchItem(
icon: Icons.brightness_2, icon: Icons.wallpaper,
title: 'AMOLED Dark', title: 'Dynamic Color',
subtitle: 'Pure black background for OLED screens', subtitle: 'Use colors from your wallpaper',
value: themeSettings.useAmoled, value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value), onChanged: (value) => ref
.read(themeProvider.notifier)
.setUseDynamicColor(value),
showDivider: false, showDivider: false,
), ),
], ],
), ),
), ),
if (!themeSettings.useDynamicColor)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _ColorPalettePicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
// Color section // Theme section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')), const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Theme'),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
SettingsSwitchItem( _ThemeModeSelector(
icon: Icons.auto_awesome, currentMode: themeSettings.themeMode,
title: 'Dynamic Color', onChanged: (mode) =>
subtitle: 'Use colors from your wallpaper', ref.read(themeProvider.notifier).setThemeMode(mode),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
showDivider: !themeSettings.useDynamicColor,
), ),
if (!themeSettings.useDynamicColor) if (Theme.of(context).brightness == Brightness.dark)
_ColorPicker( SettingsSwitchItem(
currentColor: themeSettings.seedColorValue, icon: Icons.brightness_2,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color), title: 'AMOLED Dark',
subtitle: 'Pure black background',
value: themeSettings.useAmoled,
onChanged: (value) =>
ref.read(themeProvider.notifier).setUseAmoled(value),
showDivider: false,
), ),
], ],
), ),
), ),
// Layout section // Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')), const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Layout'),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
_HistoryViewSelector( _HistoryViewSelector(
currentMode: settings.historyViewMode, currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode), onChanged: (mode) => ref
.read(settingsProvider.notifier)
.setHistoryViewMode(mode),
), ),
], ],
), ),
), ),
// Fill remaining for scroll // Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(height: 32),
),
], ],
), ),
), ),
@@ -96,11 +136,275 @@ class AppearanceSettingsPage extends ConsumerWidget {
} }
} }
/// A simplified preview of how the app looks with current settings
class _ThemePreviewCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary(
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme
.surfaceContainerHighest, // Background similar to reference
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Decorative background blobs
Positioned(
top: -50,
right: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
),
),
),
// Foreground "fake UI"
Center(
child: Container(
width: 260,
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12, // Reduced from 20 for performance
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
// Fake Album Art
Container(
width: 108,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
),
),
const SizedBox(width: 16),
// Fake Text Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
children: [
Icon(
Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
),
],
),
],
),
),
],
),
),
),
// Label badge
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? 'Dark Mode' : 'Light Mode',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}
class _ColorPalettePicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPalettePicker({
required this.currentColor,
required this.onColorSelected,
});
static const _colors = [
Color(0xFF1DB954),
Color(0xFF6750A4),
Color(0xFF0061A4),
Color(0xFF006E1C),
Color(0xFFBA1A1A),
Color(0xFF984061),
Color(0xFF7D5260),
Color(0xFF006874),
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => onColorSelected(color),
child: _ColorPaletteItem(color: color, isSelected: isSelected),
),
);
}).toList(),
),
);
}
}
class _ColorPaletteItem extends StatelessWidget {
final Color color;
final bool isSelected;
const _ColorPaletteItem({required this.color, required this.isSelected});
@override
Widget build(BuildContext context) {
final scheme = ColorScheme.fromSeed(
seedColor: color,
brightness: Theme.of(context).brightness,
);
final size = 64.0;
return Stack(
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Expanded(
child: Row(
children: [
Expanded(child: Container(color: scheme.primaryContainer)),
Expanded(child: Container(color: scheme.tertiaryContainer)),
],
),
),
Expanded(
child: Row(
children: [
Expanded(
child: Container(color: scheme.secondaryContainer),
),
Expanded(child: Container(color: scheme.surfaceContainer)),
],
),
),
],
),
),
if (isSelected)
Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(Icons.check, size: 16, color: scheme.primary),
),
),
),
],
);
}
}
/// Optimized app bar title with animation /// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget { class _AppBarTitle extends StatelessWidget {
final String title; final String title;
final double topPadding; final double topPadding;
const _AppBarTitle({required this.title, required this.topPadding}); const _AppBarTitle({required this.title, required this.topPadding});
@override @override
@@ -110,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + 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);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar( return FlexibleSpaceBar(
expandedTitleScale: 1.0, expandedTitleScale: 1.0,
@@ -132,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
class _ThemeModeSelector extends StatelessWidget { class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode; final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged; final ValueChanged<ThemeMode> onChanged;
const _ThemeModeSelector({required this.currentMode, required this.onChanged}); const _ThemeModeSelector({
required this.currentMode,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row(children: [ child: Row(
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)), children: [
const SizedBox(width: 8), _ThemeModeChip(
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)), icon: Icons.brightness_auto,
const SizedBox(width: 8), label: 'System',
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)), isSelected: currentMode == ThemeMode.system,
]), onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.light_mode,
label: 'Light',
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.dark_mode,
label: 'Dark',
isSelected: currentMode == ThemeMode.dark,
onTap: () => onChanged(ThemeMode.dark),
),
],
),
); );
} }
} }
@@ -154,27 +480,41 @@ class _ThemeModeChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); const _ThemeModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background // Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest // Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card) // So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) ? Color.alphaBlend(
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded( return Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor, color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) ? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null, : null,
), ),
child: Material( child: Material(
@@ -185,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [ child: Column(
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), children: [
const SizedBox(height: 6), Icon(
Text(label, style: TextStyle(fontSize: 12, icon,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), ? colorScheme.onPrimaryContainer
]), : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
), ),
), ),
), ),
@@ -200,49 +556,13 @@ class _ThemeModeChip extends StatelessWidget {
} }
} }
class _ColorPicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPicker({required this.currentColor, required this.onColorSelected});
static const _colors = [
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () => onColorSelected(color),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 44, height: 44,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
),
);
}).toList()),
]),
);
}
}
class _HistoryViewSelector extends StatelessWidget { class _HistoryViewSelector extends StatelessWidget {
final String currentMode; final String currentMode;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _HistoryViewSelector({required this.currentMode, required this.onChanged}); const _HistoryViewSelector({
required this.currentMode,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -254,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8), padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), child: Text(
'History View',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Row(
children: [
_ViewModeChip(
icon: Icons.view_list,
label: 'List',
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
icon: Icons.grid_view,
label: 'Grid',
isSelected: currentMode == 'grid',
onTap: () => onChanged('grid'),
),
],
), ),
Row(children: [
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
const SizedBox(width: 8),
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
]),
], ],
), ),
); );
@@ -272,25 +609,39 @@ class _ViewModeChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); const _ViewModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background // Unselected chips need contrast with card background
final unselectedColor = isDark final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) ? Color.alphaBlend(
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded( return Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor, color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) ? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null, : null,
), ),
child: Material( child: Material(
@@ -301,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [ child: Column(
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), children: [
const SizedBox(height: 6), Icon(
Text(label, style: TextStyle(fontSize: 12, icon,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), ? colorScheme.onPrimaryContainer
]), : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
), ),
), ),
), ),
+443 -137
View File
@@ -28,16 +28,25 @@ class DownloadSettingsPage extends ConsumerWidget {
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + 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);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar( return FlexibleSpaceBar(
expandedTitleScale: 1.0, expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text( title: Text(
'Download', 'Download',
style: TextStyle( style: TextStyle(
@@ -51,89 +60,117 @@ class DownloadSettingsPage extends ConsumerWidget {
), ),
), ),
// Service section // Service section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')), const SliverToBoxAdapter(
SliverToBoxAdapter( child: SettingsSectionHeader(title: 'Service'),
child: SettingsGroup( ),
children: [ SliverToBoxAdapter(
_ServiceSelector( child: SettingsGroup(
currentService: settings.defaultService, children: [
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service), _ServiceSelector(
), currentService: settings.defaultService,
], onChanged: (service) => ref
.read(settingsProvider.notifier)
.setDefaultService(service),
),
],
),
), ),
),
// Quality section // Quality section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')), const SliverToBoxAdapter(
SliverToBoxAdapter( child: SettingsSectionHeader(title: 'Audio Quality'),
child: SettingsGroup( ),
children: [ SliverToBoxAdapter(
SettingsSwitchItem( child: SettingsGroup(
icon: Icons.tune, children: [
title: 'Ask Before Download', SettingsSwitchItem(
subtitle: 'Choose quality for each download', icon: Icons.tune,
value: settings.askQualityBeforeDownload, title: 'Ask Before Download',
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value), subtitle: 'Choose quality for each download',
), value: settings.askQualityBeforeDownload,
if (!settings.askQualityBeforeDownload) ...[ onChanged: (value) => ref
_QualityOption( .read(settingsProvider.notifier)
title: 'FLAC Lossless', .setAskQualityBeforeDownload(value),
subtitle: '16-bit / 44.1kHz',
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
), ),
_QualityOption( if (!settings.askQualityBeforeDownload) ...[
title: 'Hi-Res FLAC', _QualityOption(
subtitle: '24-bit / up to 96kHz', title: 'FLAC Lossless',
isSelected: settings.audioQuality == 'HI_RES', subtitle: '16-bit / 44.1kHz',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'), isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('LOSSLESS'),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES'),
),
_QualityOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: false,
),
],
],
),
),
// File settings section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'File Settings'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(
context,
ref,
settings.filenameFormat,
),
), ),
_QualityOption( SettingsItem(
title: 'Hi-Res FLAC Max', icon: Icons.folder_outlined,
subtitle: '24-bit / up to 192kHz', title: 'Download Directory',
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS', subtitle: settings.downloadDirectory.isEmpty
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'), ? (Platform.isIOS
? 'App Documents Folder'
: 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
subtitle: _getFolderOrganizationLabel(
settings.folderOrganization,
),
onTap: () => _showFolderOrganizationPicker(
context,
ref,
settings.folderOrganization,
),
showDivider: false, showDivider: false,
), ),
], ],
], ),
), ),
),
// File settings section const SliverToBoxAdapter(child: SizedBox(height: 32)),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'File Settings')), ],
SliverToBoxAdapter( ),
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
subtitle: _getFolderOrganizationLabel(settings.folderOrganization),
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
), ),
); );
} }
@@ -141,26 +178,176 @@ class DownloadSettingsPage extends ConsumerWidget {
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current); final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final tags = [
'{artist}',
'{title}',
'{album}',
'{track}',
'{year}',
'{disc}',
];
void insertTag(String tag) {
final text = controller.text;
final selection = controller.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
String insertion = tag;
if (start > 0) {
final before = text.substring(0, start);
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
if (!before.trim().endsWith('-')) {
insertion = ' - $tag';
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
// If ends with '-' but no space, add space
insertion = ' $tag';
}
}
final newText = text.replaceRange(start, end, insertion);
controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: start + insertion.length),
);
}
showModalBottomSheet( showModalBottomSheet(
context: context, isScrollControlled: true, context: context,
backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => Padding( builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24), padding: EdgeInsets.only(
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ bottom: MediaQuery.of(context).viewInsets.bottom,
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), ),
const SizedBox(height: 16), child: SingleChildScrollView(
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true), child: SafeArea(
const SizedBox(height: 16), child: Padding(
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}', padding: const EdgeInsets.all(24),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), child: Column(
const SizedBox(height: 24), mainAxisSize: MainAxisSize.min,
Row(mainAxisAlignment: MainAxisAlignment.end, children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), children: [
const SizedBox(width: 8), Center(
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')), child: Container(
]), width: 32,
]), height: 4,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'Filename Format',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Customize how your files are named.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: '{artist} - {title}',
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
autofocus: true,
),
const SizedBox(height: 24),
Text(
'Tap to insert tag:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: tags.map((tag) {
return ActionChip(
label: Text(tag),
onPressed: () => insertTag(tag),
backgroundColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
side: BorderSide.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
labelStyle: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
);
}).toList(),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.setFilenameFormat(controller.text);
Navigator.pop(context);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Save Format'),
),
),
],
),
const SizedBox(height: 8),
],
),
),
),
),
), ),
); );
} }
@@ -172,7 +359,9 @@ class DownloadSettingsPage extends ConsumerWidget {
} else { } else {
// Android: Use file picker // Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result); if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
} }
} }
@@ -181,7 +370,9 @@ class DownloadSettingsPage extends ConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea( builder: (ctx) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -189,13 +380,20 @@ class DownloadSettingsPage extends ConsumerWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), child: Text(
'Download Location',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text( child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.', 'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
ListTile( ListTile(
@@ -205,7 +403,9 @@ class DownloadSettingsPage extends ConsumerWidget {
trailing: Icon(Icons.check_circle, color: colorScheme.primary), trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async { onTap: () async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path); ref
.read(settingsProvider.notifier)
.setDownloadDirectory(dir.path);
if (ctx.mounted) Navigator.pop(ctx); if (ctx.mounted) Navigator.pop(ctx);
}, },
), ),
@@ -218,7 +418,9 @@ class DownloadSettingsPage extends ConsumerWidget {
// Note: iOS requires folder to have at least one file to be selectable // Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result); ref
.read(settingsProvider.notifier)
.setDownloadDirectory(result);
} }
}, },
), ),
@@ -232,12 +434,18 @@ class DownloadSettingsPage extends ConsumerWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.', 'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer), style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
), ),
), ),
], ],
@@ -264,12 +472,18 @@ class DownloadSettingsPage extends ConsumerWidget {
} }
} }
void _showFolderOrganizationPicker(BuildContext context, WidgetRef ref, String current) { void _showFolderOrganizationPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea( builder: (context) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -277,39 +491,61 @@ class DownloadSettingsPage extends ConsumerWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Folder Organization', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text('Organize downloaded files into folders', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), child: Text(
'Organize downloaded files into folders',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
), ),
_FolderOption( _FolderOption(
title: 'None', title: 'None',
subtitle: 'All files in download folder', subtitle: 'All files in download folder',
example: 'SpotiFLAC/Track.flac', example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none', isSelected: current == 'none',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); }, onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('none');
Navigator.pop(context);
},
), ),
_FolderOption( _FolderOption(
title: 'By Artist', title: 'By Artist',
subtitle: 'Separate folder for each artist', subtitle: 'Separate folder for each artist',
example: 'SpotiFLAC/Artist Name/Track.flac', example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist', isSelected: current == 'artist',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); }, onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
Navigator.pop(context);
},
), ),
_FolderOption( _FolderOption(
title: 'By Album', title: 'By Album',
subtitle: 'Separate folder for each album', subtitle: 'Separate folder for each album',
example: 'SpotiFLAC/Album Name/Track.flac', example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album', isSelected: current == 'album',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); }, onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('album');
Navigator.pop(context);
},
), ),
_FolderOption( _FolderOption(
title: 'By Artist & Album', title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album', subtitle: 'Nested folders for artist and album',
example: 'SpotiFLAC/Artist/Album/Track.flac', example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album', isSelected: current == 'artist_album',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); }, onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
Navigator.pop(context);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -322,19 +558,39 @@ class DownloadSettingsPage extends ConsumerWidget {
class _ServiceSelector extends StatelessWidget { class _ServiceSelector extends StatelessWidget {
final String currentService; final String currentService;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _ServiceSelector({required this.currentService, required this.onChanged}); const _ServiceSelector({
required this.currentService,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row(children: [ child: Row(
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')), children: [
const SizedBox(width: 8), _ServiceChip(
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')), icon: Icons.music_note,
const SizedBox(width: 8), label: 'Tidal',
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')), isSelected: currentService == 'tidal',
]), onTap: () => onChanged('tidal'),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: currentService == 'qobuz',
onTap: () => onChanged('qobuz'),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: currentService == 'amazon',
onTap: () => onChanged('amazon'),
),
],
),
); );
} }
} }
@@ -344,17 +600,25 @@ class _ServiceChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); const _ServiceChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: colorScheme.surfaceContainerHigh; : colorScheme.surfaceContainerHigh;
return Expanded( return Expanded(
child: Material( child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor, color: isSelected ? colorScheme.primaryContainer : unselectedColor,
@@ -364,13 +628,29 @@ class _ServiceChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [ child: Column(
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), children: [
const SizedBox(height: 6), Icon(
Text(label, style: TextStyle(fontSize: 12, icon,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), ? colorScheme.onPrimaryContainer
]), : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
), ),
), ),
), ),
@@ -384,7 +664,13 @@ class _QualityOption extends StatelessWidget {
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final bool showDivider; final bool showDivider;
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true}); const _QualityOption({
required this.title,
required this.subtitle,
required this.isSelected,
required this.onTap,
this.showDivider = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -404,11 +690,16 @@ class _QualityOption extends StatelessWidget {
children: [ children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge), Text(title, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
], ],
), ),
), ),
isSelected isSelected
? Icon(Icons.check_circle, color: colorScheme.primary) ? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline), : Icon(Icons.circle_outlined, color: colorScheme.outline),
], ],
@@ -434,7 +725,13 @@ class _FolderOption extends StatelessWidget {
final String example; final String example;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
const _FolderOption({required this.title, required this.subtitle, required this.example, required this.isSelected, required this.onTap}); const _FolderOption({
required this.title,
required this.subtitle,
required this.example,
required this.isSelected,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -447,10 +744,19 @@ class _FolderOption extends StatelessWidget {
children: [ children: [
Text(subtitle), Text(subtitle),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(example, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: colorScheme.primary)), Text(
example,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: colorScheme.primary,
),
),
], ],
), ),
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), trailing: isSelected
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: onTap, onTap: onTap,
); );
} }
+801
View File
@@ -0,0 +1,801 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class LogScreen extends StatefulWidget {
const LogScreen({super.key});
@override
State<LogScreen> createState() => _LogScreenState();
}
class _LogScreenState extends State<LogScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
String _selectedLevel = 'ALL';
String _searchQuery = '';
bool _autoScroll = true;
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
@override
void initState() {
super.initState();
LogBuffer().addListener(_onLogUpdate);
// Start polling Go backend logs
LogBuffer().startGoLogPolling();
}
@override
void dispose() {
LogBuffer().removeListener(_onLogUpdate);
// Stop polling when leaving screen
LogBuffer().stopGoLogPolling();
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onLogUpdate() {
if (mounted) {
setState(() {});
if (_autoScroll && _scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
});
}
}
}
List<LogEntry> get _filteredLogs {
return LogBuffer().filter(
level: _selectedLevel,
search: _searchQuery.isEmpty ? null : _searchQuery,
);
}
void _copyLogs() {
final logs = LogBuffer().export();
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Logs copied to clipboard'),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
}
void _shareLogs() {
final logs = LogBuffer().export();
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
}
void _clearLogs() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
LogBuffer().clear();
Navigator.pop(context);
},
child: const Text('Clear'),
),
],
),
);
}
Color _getLevelColor(String level, ColorScheme colorScheme) {
switch (level) {
case 'ERROR':
case 'FATAL':
return colorScheme.error;
case 'WARN':
return Colors.orange;
case 'INFO':
return colorScheme.primary;
case 'DEBUG':
default:
return colorScheme.onSurfaceVariant;
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final logs = _filteredLogs;
return PopScope(
canPop: true,
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),
),
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,
),
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Clear logs'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
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);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Logs',
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Filter section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Filter'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
// Level filter
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
'Filter logs by severity',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<String>(
value: _selectedLevel,
underline: const SizedBox(),
items: _levels.map((level) {
return DropdownMenuItem(
value: level,
child: Text(
level,
style: TextStyle(
color: level == 'ALL'
? colorScheme.onSurface
: _getLevelColor(level, colorScheme),
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() => _selectedLevel = value);
}
},
),
],
),
),
Divider(
height: 1,
indent: 56,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search logs...',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
),
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
),
],
),
),
],
),
),
// Log entries section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
),
),
// Error summary card - shows detected issues
SliverToBoxAdapter(
child: _LogSummaryCard(logs: LogBuffer().entries),
),
// Log list
logs.isEmpty
? SliverToBoxAdapter(
child: SettingsGroup(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.article_outlined,
size: 48,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'No logs yet',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Logs will appear here as you use the app',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
],
),
)
: SliverToBoxAdapter(
child: SettingsGroup(
children: [
...logs.asMap().entries.map((entry) {
final index = entry.key;
final log = entry.value;
return _LogEntryTile(
entry: log,
levelColor: _getLevelColor(log.level, colorScheme),
showDivider: index < logs.length - 1,
);
}),
],
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
}
class _LogEntryTile extends StatelessWidget {
final LogEntry entry;
final Color levelColor;
final bool showDivider;
const _LogEntryTile({
required this.entry,
required this.levelColor,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isError = entry.level == 'ERROR' || entry.level == 'FATAL';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: isError
? colorScheme.errorContainer.withValues(alpha: 0.2)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: time, level, tag
Row(
children: [
Text(
entry.formattedTime,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
entry.level,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: levelColor,
),
),
),
if (entry.isFromGo) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.teal.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Go',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.teal,
),
),
),
],
const SizedBox(width: 8),
Expanded(
child: Text(
entry.tag,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
// Message
Text(
entry.message,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: colorScheme.onSurface,
height: 1.4,
),
),
// Error if present
if (entry.error != null) ...[
const SizedBox(height: 4),
Text(
entry.error!,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: colorScheme.error,
height: 1.3,
),
),
],
],
),
),
if (showDivider)
Divider(
height: 1,
indent: 20,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
/// Summary card showing detected issues in logs
class _LogSummaryCard extends StatelessWidget {
final List<LogEntry> logs;
const _LogSummaryCard({required this.logs});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Analyze logs for issues
final analysis = _analyzeLogs();
// Don't show if no issues detected
if (!analysis.hasIssues) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Card(
elevation: 0,
color: analysis.hasISPBlocking
? colorScheme.errorContainer.withValues(alpha: 0.5)
: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded,
size: 20,
color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary,
),
const SizedBox(width: 8),
Text(
'Issue Summary',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 12),
// ISP Blocking detected
if (analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.block,
label: 'ISP BLOCKING DETECTED',
description: 'Your ISP may be blocking access to download services',
suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
color: colorScheme.error,
domains: analysis.blockedDomains,
),
const SizedBox(height: 8),
],
// Rate limiting
if (analysis.hasRateLimit) ...[
_IssueBadge(
icon: Icons.speed,
label: 'RATE LIMITED',
description: 'Too many requests to the service',
suggestion: 'Wait a few minutes before trying again',
color: Colors.orange,
),
const SizedBox(height: 8),
],
// Network errors
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.wifi_off,
label: 'NETWORK ERROR',
description: 'Connection issues detected',
suggestion: 'Check your internet connection',
color: colorScheme.tertiary,
),
const SizedBox(height: 8),
],
// Track not found
if (analysis.hasNotFound) ...[
_IssueBadge(
icon: Icons.search_off,
label: 'TRACK NOT FOUND',
description: 'Some tracks could not be found on download services',
suggestion: 'The track may not be available in lossless quality',
color: colorScheme.onSurfaceVariant,
),
],
// Error count
const SizedBox(height: 12),
Text(
'Total errors: ${analysis.errorCount}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
_LogAnalysis _analyzeLogs() {
int errorCount = 0;
bool hasISPBlocking = false;
bool hasRateLimit = false;
bool hasNetworkError = false;
bool hasNotFound = false;
final Set<String> blockedDomains = {};
for (final log in logs) {
if (log.level == 'ERROR' || log.level == 'FATAL') {
errorCount++;
}
final msgLower = log.message.toLowerCase();
final errorLower = (log.error ?? '').toLowerCase();
final combined = '$msgLower $errorLower';
// Check for ISP blocking (detected by Go backend)
if (combined.contains('isp blocking') ||
combined.contains('isp may be') ||
combined.contains('blocked by isp') ||
combined.contains('connection reset') ||
combined.contains('connection refused')) {
hasISPBlocking = true;
// Try to extract domain
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!);
}
}
// Check for rate limiting
if (combined.contains('rate limit') ||
combined.contains('429') ||
combined.contains('too many requests')) {
hasRateLimit = true;
}
// Check for network errors
if (combined.contains('connection') ||
combined.contains('timeout') ||
combined.contains('network') ||
combined.contains('dial')) {
hasNetworkError = true;
}
// Check for not found
if (combined.contains('not found') ||
combined.contains('no results') ||
combined.contains('could not find')) {
hasNotFound = true;
}
}
return _LogAnalysis(
errorCount: errorCount,
hasISPBlocking: hasISPBlocking,
hasRateLimit: hasRateLimit,
hasNetworkError: hasNetworkError,
hasNotFound: hasNotFound,
blockedDomains: blockedDomains.toList(),
);
}
}
class _LogAnalysis {
final int errorCount;
final bool hasISPBlocking;
final bool hasRateLimit;
final bool hasNetworkError;
final bool hasNotFound;
final List<String> blockedDomains;
_LogAnalysis({
required this.errorCount,
required this.hasISPBlocking,
required this.hasRateLimit,
required this.hasNetworkError,
required this.hasNotFound,
required this.blockedDomains,
});
bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound;
}
class _IssueBadge extends StatelessWidget {
final IconData icon;
final String label;
final String description;
final String suggestion;
final Color color;
final List<String>? domains;
const _IssueBadge({
required this.icon,
required this.label,
required this.description,
required this.suggestion,
required this.color,
this.domains,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 6),
Text(
description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
),
if (domains != null && domains!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Affected: ${domains!.join(", ")}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontSize: 11,
),
),
],
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary),
const SizedBox(width: 4),
Expanded(
child: Text(
suggestion,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
),
],
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+29 -13
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
import 'package:spotiflac_android/screens/settings/download_settings_page.dart'; import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget { class SettingsTab extends ConsumerWidget {
@@ -13,29 +14,38 @@ class SettingsTab extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
// Collapsing App Bar // Collapsing App Bar
SliverAppBar( SliverAppBar(
expandedHeight: 130, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar( flexibleSpace: LayoutBuilder(
expandedTitleScale: 1.3, builder: (context, constraints) {
titlePadding: const EdgeInsets.only(left: 24, bottom: 16), final maxHeight = 120 + topPadding;
title: Text( final minHeight = kToolbarHeight + topPadding;
'Settings', final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
style: TextStyle(
fontSize: 28, return FlexibleSpaceBar(
fontWeight: FontWeight.bold, expandedTitleScale: 1.0,
color: colorScheme.onSurface, titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
), title: Text(
), 'Settings',
style: TextStyle(
fontSize: 20 + (14 * expandRatio), // 20 -> 34
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
), ),
), ),
@@ -67,10 +77,16 @@ class SettingsTab extends ConsumerWidget {
), ),
), ),
// Second group: About // Second group: Logs & About
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
SettingsItem(
icon: Icons.article_outlined,
title: 'Logs',
subtitle: 'View app logs for debugging',
onTap: () => _navigateTo(context, const LogScreen()),
),
SettingsItem( SettingsItem(
icon: Icons.info_outline, icon: Icons.info_outline,
title: 'About', title: 'About',
+83 -19
View File
@@ -66,24 +66,38 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} }
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
// Check storage permission // Check storage permission
PermissionStatus storageStatus; bool storageGranted = false;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
storageStatus = await Permission.audio.status; // Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
final manageStatus = await Permission.manageExternalStorage.status;
final audioStatus = await Permission.audio.status;
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) { } else if (_androidSdkVersion >= 30) {
storageStatus = await Permission.manageExternalStorage.status; // Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
final manageStatus = await Permission.manageExternalStorage.status;
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
storageGranted = manageStatus.isGranted;
} else { } else {
storageStatus = await Permission.storage.status; // Android 10 and below: Use legacy storage permission
final storageStatus = await Permission.storage.status;
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
storageGranted = storageStatus.isGranted;
} }
debugPrint('[Permission] Final storageGranted=$storageGranted');
// Check notification permission (Android 13+) // Check notification permission (Android 13+)
PermissionStatus notificationStatus = PermissionStatus.granted; PermissionStatus notificationStatus = PermissionStatus.granted;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
notificationStatus = await Permission.notification.status; notificationStatus = await Permission.notification.status;
debugPrint('[Permission] Notification=$notificationStatus');
} }
if (mounted) { if (mounted) {
setState(() { setState(() {
_storagePermissionGranted = storageStatus.isGranted; _storagePermissionGranted = storageGranted;
_notificationPermissionGranted = notificationStatus.isGranted; _notificationPermissionGranted = notificationStatus.isGranted;
}); });
} }
@@ -97,17 +111,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (Platform.isIOS) { if (Platform.isIOS) {
setState(() => _storagePermissionGranted = true); setState(() => _storagePermissionGranted = true);
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
PermissionStatus status; bool allGranted = false;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
// Android 13+: Use audio permission // Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
status = await Permission.audio.request();
// First check/request MANAGE_EXTERNAL_STORAGE
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Access Required'),
content: const Text(
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
'Please enable "Allow access to manage all files" in the next screen.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Open Settings'),
),
],
),
);
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
// Re-check after returning from settings
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
}
// Then request READ_MEDIA_AUDIO (this shows a dialog)
var audioStatus = await Permission.audio.status;
if (!audioStatus.isGranted && manageStatus.isGranted) {
audioStatus = await Permission.audio.request();
}
allGranted = manageStatus.isGranted && audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) { } else if (_androidSdkVersion >= 30) {
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE // Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
// This opens system settings, not a dialog var manageStatus = await Permission.manageExternalStorage.status;
status = await Permission.manageExternalStorage.status; if (!manageStatus.isGranted) {
if (!status.isGranted) {
// Show explanation dialog first
if (mounted) { if (mounted) {
final shouldOpen = await showDialog<bool>( final shouldOpen = await showDialog<bool>(
context: context, context: context,
@@ -131,23 +185,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
); );
if (shouldOpen == true) { if (shouldOpen == true) {
status = await Permission.manageExternalStorage.request(); await Permission.manageExternalStorage.request();
// Re-check after returning from settings
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
} }
} }
} }
allGranted = manageStatus.isGranted;
} else { } else {
// Android 10 and below: Use legacy storage permission // Android 10 and below: Use legacy storage permission
status = await Permission.storage.request(); final status = await Permission.storage.request();
allGranted = status.isGranted;
if (status.isPermanentlyDenied) {
_showPermissionDeniedDialog('Storage');
setState(() => _isLoading = false);
return;
}
} }
if (status.isGranted) { if (allGranted) {
setState(() => _storagePermissionGranted = true); setState(() => _storagePermissionGranted = true);
} else if (status.isPermanentlyDenied) {
_showPermissionDeniedDialog('Storage');
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')), const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
); );
} }
} }
+104 -79
View File
@@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final file = File(widget.item.filePath); final file = File(widget.item.filePath);
final exists = await file.exists(); final exists = await file.exists();
int? size; int? size;
if (exists) { if (exists) {
try { try {
size = await file.length(); size = await file.length();
} catch (_) {} } catch (_) {}
} }
if (mounted) { if (mounted) {
setState(() { setState(() {
_fileExists = exists; _fileExists = exists;
@@ -55,7 +57,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
// Use data directly from history item (cached from download)
DownloadHistoryItem get item => widget.item; DownloadHistoryItem get item => widget.item;
String get trackName => item.trackName;
String get artistName => item.artistName;
String get albumName => item.albumName;
String? get albumArtist => item.albumArtist;
int? get trackNumber => item.trackNumber;
int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
int? get bitDepth => item.bitDepth;
int? get sampleRate => item.sampleRate;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Track name // Track name (from file metadata)
Text( Text(
item.trackName, trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colorScheme.onSurface, color: colorScheme.onSurface,
@@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Artist name // Artist name (from file metadata)
Text( Text(
item.artistName, artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Album name // Album name (from file metadata)
Row( Row(
children: [ children: [
Icon( Icon(
@@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
item.albumName, albumName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -340,19 +353,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Metadata grid // Metadata grid
_buildMetadataGrid(context, colorScheme), _buildMetadataGrid(context, colorScheme),
// Spotify link button // Streaming service link button
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[ if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
OutlinedButton.icon( Builder(
onPressed: () => _openSpotifyUrl(context), builder: (context) {
icon: const Icon(Icons.open_in_new, size: 18), final isDeezer = item.spotifyId!.contains('deezer');
label: const Text('Open in Spotify'), return OutlinedButton.icon(
style: OutlinedButton.styleFrom( onPressed: () => _openServiceUrl(context),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), icon: const Icon(Icons.open_in_new, size: 18),
shape: RoundedRectangleBorder( label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
borderRadius: BorderRadius.circular(12), style: OutlinedButton.styleFrom(
), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
), ),
], ],
], ],
@@ -361,16 +379,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
); );
} }
Future<void> _openSpotifyUrl(BuildContext context) async { Future<void> _openServiceUrl(BuildContext context) async {
if (item.spotifyId == null) return; if (item.spotifyId == null) return;
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}'; final isDeezer = item.spotifyId!.contains('deezer');
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}'); final rawId = item.spotifyId!.replaceAll('deezer:', '');
final webUrl = isDeezer
? 'https://www.deezer.com/track/$rawId'
: 'https://open.spotify.com/track/$rawId';
final appUri = isDeezer
? Uri.parse('deezer://www.deezer.com/track/$rawId')
: Uri.parse('spotify:track:$rawId');
try { try {
// Try to open in Spotify app first using URI scheme // Try to open in App first using URI scheme
final launched = await launchUrl( final launched = await launchUrl(
spotifyUri, appUri,
mode: LaunchMode.externalApplication, mode: LaunchMode.externalApplication,
); );
@@ -393,7 +419,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (context.mounted) { if (context.mounted) {
_copyToClipboard(context, webUrl); _copyToClipboard(context, webUrl);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify URL copied to clipboard')), SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
); );
} }
} }
@@ -401,31 +427,43 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Build audio quality string from file metadata
String? audioQualityStr;
if (bitDepth != null && sampleRate != null) {
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
}
final items = <_MetadataItem>[ final items = <_MetadataItem>[
_MetadataItem('Track name', item.trackName), _MetadataItem('Track name', trackName),
_MetadataItem('Artist', item.artistName), _MetadataItem('Artist', artistName),
if (item.albumArtist != null && item.albumArtist != item.artistName) if (albumArtist != null && albumArtist != artistName)
_MetadataItem('Album artist', item.albumArtist!), _MetadataItem('Album artist', albumArtist!),
_MetadataItem('Album', item.albumName), _MetadataItem('Album', albumName),
if (item.trackNumber != null) if (trackNumber != null && trackNumber! > 0)
_MetadataItem('Track number', item.trackNumber.toString()), _MetadataItem('Track number', trackNumber.toString()),
if (item.discNumber != null && item.discNumber! > 1) if (discNumber != null && discNumber! > 0)
_MetadataItem('Disc number', item.discNumber.toString()), _MetadataItem('Disc number', discNumber.toString()),
if (item.duration != null) if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)), _MetadataItem('Duration', _formatDuration(item.duration!)),
if (item.quality != null && item.quality!.contains('bit')) if (audioQualityStr != null)
_MetadataItem('Audio quality', item.quality!), _MetadataItem('Audio quality', audioQualityStr),
if (item.releaseDate != null && item.releaseDate!.isNotEmpty) if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem('Release date', item.releaseDate!), _MetadataItem('Release date', releaseDate!),
if (item.isrc != null && item.isrc!.isNotEmpty) if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', item.isrc!), _MetadataItem('ISRC', isrc!),
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ];
_MetadataItem('Spotify ID', item.spotifyId!),
if (item.quality != null && item.quality!.isNotEmpty) if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
_MetadataItem('Quality', _formatQuality(item.quality!)), final isDeezer = item.spotifyId!.contains('deezer');
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
}
items.addAll([
_MetadataItem('Service', item.service.toUpperCase()), _MetadataItem('Service', item.service.toUpperCase()),
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)), _MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
]; ]);
return Column( return Column(
children: items.map((metadata) { children: items.map((metadata) {
@@ -476,32 +514,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return '$minutes:${secs.toString().padLeft(2, '0')}'; return '$minutes:${secs.toString().padLeft(2, '0')}';
} }
String _formatQuality(String quality) {
switch (quality) {
case 'LOSSLESS':
return 'Lossless (16-bit)';
case 'HI_RES':
return 'Hi-Res (24-bit)';
case 'HI_RES_LOSSLESS':
return 'Hi-Res Lossless (24-bit)';
default:
return quality;
}
}
String _formatQualityShort(String quality) {
switch (quality) {
case 'LOSSLESS':
return '16-bit';
case 'HI_RES':
return '24-bit';
case 'HI_RES_LOSSLESS':
return 'Hi-Res';
default:
return quality;
}
}
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
final fileName = item.filePath.split(Platform.pathSeparator).last; final fileName = item.filePath.split(Platform.pathSeparator).last;
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
@@ -570,7 +582,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
), ),
if (item.quality != null) if (bitDepth != null && sampleRate != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -578,7 +590,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
_formatQualityShort(item.quality!), '$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
style: TextStyle( style: TextStyle(
color: colorScheme.onTertiaryContainer, color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -891,7 +903,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
ListTile( ListTile(
leading: Icon(Icons.delete, color: colorScheme.error), leading: Icon(Icons.delete, color: colorScheme.error),
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)), title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_confirmDelete(context, ref, colorScheme); _confirmDelete(context, ref, colorScheme);
@@ -908,10 +920,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Remove from history?'), title: const Text('Remove from device?'),
content: const Text( content: const Text(
'This will remove the track from your download history. ' 'This will permanently delete the downloaded file and remove it from your history.',
'The downloaded file will not be deleted.',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -919,12 +930,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
// Delete the file first
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
debugPrint('Failed to delete file: $e');
}
// Remove from history
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Go back to history if (context.mounted) {
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Go back to history
}
}, },
child: Text('Remove', style: TextStyle(color: colorScheme.error)), child: Text('Delete', style: TextStyle(color: colorScheme.error)),
), ),
], ],
), ),
+256
View File
@@ -0,0 +1,256 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
class CsvImportService {
static final _log = AppLogger('CsvImportService');
/// Pick and parse CSV file, then enrich metadata from Deezer
/// [onProgress] callback receives (current, total) for progress updates
static Future<List<Track>> pickAndParseCsv({
void Function(int current, int total)? onProgress,
}) async {
try {
final FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final content = await file.readAsString();
final tracks = _parseCsv(content);
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
}
return tracks;
}
} catch (e) {
_log.e('Error picking/parsing CSV: $e');
}
return [];
}
/// Enrich tracks with metadata from Deezer using ISRC or search
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
static Future<List<Track>> _enrichTracksMetadata(
List<Track> tracks, {
void Function(int current, int total)? onProgress,
}) async {
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
final enrichedTracks = <Track>[];
for (int i = 0; i < tracks.length; i++) {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
// Only enrich if missing cover/duration
if (track.coverUrl == null || track.duration == 0) {
Map<String, dynamic>? trackData;
// Try ISRC first if available
if (track.isrc != null && track.isrc!.isNotEmpty) {
try {
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
_log.d('ISRC enrichment success for ${track.name}');
} catch (e) {
_log.w('ISRC search failed for ${track.name}, trying text search...');
}
}
// Fallback to text search if ISRC failed or not available
if (trackData == null) {
try {
final query = '${track.artistName} ${track.name}';
final searchResult = await PlatformBridge.searchDeezerAll(query, trackLimit: 5);
if (searchResult.containsKey('tracks')) {
final tracksList = searchResult['tracks'] as List<dynamic>?;
if (tracksList != null && tracksList.isNotEmpty) {
// Find best match by comparing names
for (final result in tracksList) {
final resultMap = result as Map<String, dynamic>;
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
final trackNameLower = track.name.toLowerCase();
// Check if track name matches (contains or equals)
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName');
break;
}
}
// If no exact match, use first result
if (trackData == null && tracksList.isNotEmpty) {
trackData = tracksList.first as Map<String, dynamic>;
_log.d('Using first search result for ${track.name}');
}
}
}
} catch (e) {
_log.w('Text search also failed for ${track.name}: $e');
}
}
// Apply enriched data if found
if (trackData != null) {
final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0;
final deezerIdRaw = trackData['spotify_id'] as String?;
enrichedTracks.add(Track(
id: deezerIdRaw ?? track.id,
name: trackData['name'] as String? ?? track.name,
artistName: trackData['artists'] as String? ?? track.artistName,
albumName: trackData['album_name'] as String? ?? track.albumName,
albumArtist: trackData['album_artist'] as String?,
coverUrl: coverUrl ?? track.coverUrl,
isrc: trackData['isrc'] as String? ?? track.isrc,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
));
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
// Small delay to avoid rate limiting
if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100));
}
continue;
}
}
// Keep original track if enrichment failed or not needed
enrichedTracks.add(track);
}
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
return enrichedTracks;
}
static List<Track> _parseCsv(String content) {
final List<Track> tracks = [];
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
if (lines.isEmpty) return tracks;
// Detect headers line (assume first non-empty line)
int startIdx = 0;
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
startIdx++;
}
if (startIdx >= lines.length) return tracks;
final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) {
// Normalize header: lowercase, trim, remove quotes
String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i;
}
_log.d('CSV Headers: ${colMap.keys.toList()}');
// Parse rows
for (int i = startIdx + 1; i < lines.length; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
final values = _parseLine(line);
// Helper to get value securely
String? getVal(List<String> keys) {
return _getValue(values, colMap, keys);
}
String? trackName = getVal(['track name', 'track', 'name', 'title']);
String? artistName = getVal(['artist name', 'artist']);
String? albumName = getVal(['album name', 'album']);
String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing
// If 'spotify uri' contains the id: 'spotify:track:ID'
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', '');
}
// Basic validation: Need at least name and artist, OR a spotify ID
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
tracks.add(Track(
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
name: trackName ?? 'Unknown Track',
artistName: artistName ?? 'Unknown Artist',
albumName: albumName ?? 'Unknown Album',
isrc: isrc,
duration: 0, // Will be updated by enrichment later
coverUrl: null, // Will be fetched by enrichment
));
}
}
_log.i('Parsed ${tracks.length} tracks from CSV');
return tracks;
}
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
for (final key in possibleKeys) {
if (colMap.containsKey(key)) {
final index = colMap[key]!;
if (index < values.length) {
return _cleanValue(values[index]);
}
}
}
return null;
}
static String _cleanValue(String val) {
val = val.trim();
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
val = val.substring(1, val.length - 1);
}
// Handle double quotes escape in CSV ("" -> ")
val = val.replaceAll('""', '"');
return val;
}
// Robust CSV Line Parser
static List<String> _parseLine(String line) {
final List<String> result = [];
bool inQuote = false;
StringBuffer buffer = StringBuffer();
for (int i=0; i<line.length; i++) {
String char = line[i];
if (char == '"') {
// Look ahead to check for escaped quote
if (i + 1 < line.length && line[i+1] == '"') {
buffer.write('"'); // Keep format for now, _cleanValue handles unescaping logic differently...
// Wait, standard CSV: "Thumb ""Up""" -> Thumb "Up"
// My _cleanValue handles it, so I should just preserve raw content here mostly,
// BUT I need to know if " toggles inQuote.
// Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote).
buffer.write('"'); // Write 1st quote
i++; // Skip next quote char loop
buffer.write('"'); // Write 2nd quote
} else {
inQuote = !inQuote;
buffer.write(char);
}
} else if (char == ',' && !inQuote) {
result.add(buffer.toString());
buffer.clear();
} else {
buffer.write(char);
}
}
result.add(buffer.toString());
return result;
}
}
+78 -3
View File
@@ -1,5 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PlatformBridge');
/// Bridge to communicate with Go backend via platform channels /// Bridge to communicate with Go backend via platform channels
class PlatformBridge { class PlatformBridge {
@@ -7,18 +10,21 @@ class PlatformBridge {
/// Parse and validate Spotify URL /// Parse and validate Spotify URL
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async { static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
_log.d('parseSpotifyUrl: $url');
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get Spotify metadata from URL /// Get Spotify metadata from URL
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async { static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
_log.d('getSpotifyMetadata: $url');
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Search Spotify /// Search Spotify
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async { static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
_log.d('searchSpotify: "$query" (limit: $limit)');
final result = await _channel.invokeMethod('searchSpotify', { final result = await _channel.invokeMethod('searchSpotify', {
'query': query, 'query': query,
'limit': limit, 'limit': limit,
@@ -28,6 +34,7 @@ class PlatformBridge {
/// Search Spotify for tracks and artists /// Search Spotify for tracks and artists
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
_log.d('searchSpotifyAll: "$query"');
final result = await _channel.invokeMethod('searchSpotifyAll', { final result = await _channel.invokeMethod('searchSpotifyAll', {
'query': query, 'query': query,
'track_limit': trackLimit, 'track_limit': trackLimit,
@@ -38,6 +45,7 @@ class PlatformBridge {
/// Check track availability on streaming services /// Check track availability on streaming services
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async { static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', { final result = await _channel.invokeMethod('checkAvailability', {
'spotify_id': spotifyId, 'spotify_id': spotifyId,
'isrc': isrc, 'isrc': isrc,
@@ -67,6 +75,7 @@ class PlatformBridge {
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
}) async { }) async {
_log.i('downloadTrack: "$trackName" by $artistName via $service');
final request = jsonEncode({ final request = jsonEncode({
'isrc': isrc, 'isrc': isrc,
'service': service, 'service': service,
@@ -90,7 +99,13 @@ class PlatformBridge {
}); });
final result = await _channel.invokeMethod('downloadTrack', request); final result = await _channel.invokeMethod('downloadTrack', request);
return jsonDecode(result as String) as Map<String, dynamic>; final response = jsonDecode(result as String) as Map<String, dynamic>;
if (response['success'] == true) {
_log.i('Download success: ${response['file_path']}');
} else {
_log.w('Download failed: ${response['error']}');
}
return response;
} }
/// Download with automatic fallback to other services /// Download with automatic fallback to other services
@@ -111,10 +126,11 @@ class PlatformBridge {
int discNumber = 1, int discNumber = 1,
int totalTracks = 1, int totalTracks = 1,
String? releaseDate, String? releaseDate,
String preferredService = 'qobuz', String preferredService = 'tidal',
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
}) async { }) async {
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
final request = jsonEncode({ final request = jsonEncode({
'isrc': isrc, 'isrc': isrc,
'service': preferredService, 'service': preferredService,
@@ -138,7 +154,22 @@ class PlatformBridge {
}); });
final result = await _channel.invokeMethod('downloadWithFallback', request); final result = await _channel.invokeMethod('downloadWithFallback', request);
return jsonDecode(result as String) as Map<String, dynamic>; final response = jsonDecode(result as String) as Map<String, dynamic>;
if (response['success'] == true) {
final service = response['service'] ?? 'unknown';
final filePath = response['file_path'] ?? '';
final bitDepth = response['actual_bit_depth'];
final sampleRate = response['actual_sample_rate'];
final qualityStr = bitDepth != null && sampleRate != null
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
: '';
_log.i('Download success via $service$qualityStr: $filePath');
} else {
final error = response['error'] ?? 'Unknown error';
final errorType = response['error_type'] ?? '';
_log.e('Download failed: $error (type: $errorType)');
}
return response;
} }
/// Get download progress (legacy single download) /// Get download progress (legacy single download)
@@ -248,6 +279,16 @@ class PlatformBridge {
await _channel.invokeMethod('cleanupConnections'); await _channel.invokeMethod('cleanupConnections');
} }
/// Read metadata directly from a FLAC file
/// Returns all embedded metadata (title, artist, album, track number, etc.)
/// This reads from the actual file, not from cached/database data
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Start foreground download service to keep downloads running in background /// Start foreground download service to keep downloads running in background
static Future<void> startDownloadService({ static Future<void> startDownloadService({
String trackName = '', String trackName = '',
@@ -335,6 +376,9 @@ class PlatformBridge {
'resource_type': resourceType, 'resource_type': resourceType,
'resource_id': resourceId, 'resource_id': resourceId,
}); });
if (result == null) {
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
}
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
@@ -364,4 +408,35 @@ class PlatformBridge {
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
// ==================== GO BACKEND LOGS ====================
/// Get all logs from Go backend
static Future<List<Map<String, dynamic>>> getGoLogs() async {
final result = await _channel.invokeMethod('getLogs');
final logs = jsonDecode(result as String) as List<dynamic>;
return logs.map((e) => e as Map<String, dynamic>).toList();
}
/// Get logs since a specific index (for incremental updates)
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Clear Go backend logs
static Future<void> clearGoLogs() async {
await _channel.invokeMethod('clearLogs');
}
/// Get Go backend log count
static Future<int> getGoLogCount() async {
final result = await _channel.invokeMethod('getLogCount');
return result as int;
}
/// Enable or disable Go backend logging
static Future<void> setGoLoggingEnabled(bool enabled) async {
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
}
} }
+105 -78
View File
@@ -7,11 +7,9 @@ class AppTheme {
static const Color defaultSeedColor = Color(kDefaultSeedColor); static const Color defaultSeedColor = Color(kDefaultSeedColor);
/// Create light theme /// Create light theme
static ThemeData light({ static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
ColorScheme? dynamicScheme, final scheme =
Color? seedColor, dynamicScheme ??
}) {
final scheme = dynamicScheme ??
ColorScheme.fromSeed( ColorScheme.fromSeed(
seedColor: seedColor ?? defaultSeedColor, seedColor: seedColor ?? defaultSeedColor,
brightness: Brightness.light, brightness: Brightness.light,
@@ -45,7 +43,8 @@ class AppTheme {
Color? seedColor, Color? seedColor,
bool isAmoled = false, bool isAmoled = false,
}) { }) {
final scheme = dynamicScheme ?? final scheme =
dynamicScheme ??
ColorScheme.fromSeed( ColorScheme.fromSeed(
seedColor: seedColor ?? defaultSeedColor, seedColor: seedColor ?? defaultSeedColor,
brightness: Brightness.dark, brightness: Brightness.dark,
@@ -75,34 +74,41 @@ class AppTheme {
} }
/// AppBar theme /// AppBar theme
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme( static AppBarTheme _appBarTheme(
elevation: 0, ColorScheme scheme, {
scrolledUnderElevation: isAmoled ? 0 : 3, bool isAmoled = false,
backgroundColor: isAmoled ? Colors.black : scheme.surface, }) => AppBarTheme(
foregroundColor: scheme.onSurface, elevation: 0,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint, scrolledUnderElevation: isAmoled ? 0 : 3,
centerTitle: true, backgroundColor: isAmoled ? Colors.black : scheme.surface,
titleTextStyle: TextStyle( foregroundColor: scheme.onSurface,
color: scheme.onSurface, surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
fontSize: 22, centerTitle: true,
fontWeight: FontWeight.w500, titleTextStyle: TextStyle(
), color: scheme.onSurface,
); fontSize: 22,
fontWeight: FontWeight.w500,
),
);
/// Card theme /// Card theme
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
color: scheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16),
surfaceTintColor: scheme.surfaceTint, ), // 12 -> 16
); color: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint,
);
/// Elevated button theme /// Elevated button theme
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) => static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
ElevatedButtonThemeData( ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
), ),
); );
@@ -111,7 +117,9 @@ class AppTheme {
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) => static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
FilledButtonThemeData( FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
), ),
); );
@@ -120,7 +128,9 @@ class AppTheme {
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) => static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
OutlinedButtonThemeData( OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
), ),
); );
@@ -129,7 +139,9 @@ class AppTheme {
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) => static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
TextButtonThemeData( TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
), ),
); );
@@ -147,52 +159,63 @@ class AppTheme {
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
InputDecorationTheme( InputDecorationTheme(
filled: true, filled: true,
fillColor: scheme.surfaceContainerHighest, fillColor: scheme.surfaceContainerHighest.withValues(
alpha: 0.3,
), // Added transparency
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide(color: scheme.primary, width: 2), borderSide: BorderSide(color: scheme.primary, width: 2),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide(color: scheme.error, width: 1), borderSide: BorderSide(color: scheme.error, width: 1),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
), // consistent padding
); );
/// List tile theme /// List tile theme
static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 12 -> 16
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
); );
/// Dialog theme /// Dialog theme
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData( static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
elevation: 6, elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
backgroundColor: scheme.surfaceContainerHigh, backgroundColor: scheme.surfaceContainerHigh,
surfaceTintColor: scheme.surfaceTint, surfaceTintColor: scheme.surfaceTint,
); );
/// Navigation bar theme /// Navigation bar theme
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) => static NavigationBarThemeData _navigationBarTheme(
NavigationBarThemeData( ColorScheme scheme, {
elevation: 0, bool isAmoled = false,
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer, }) => NavigationBarThemeData(
indicatorColor: scheme.secondaryContainer, elevation: 0,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint, backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, indicatorColor: scheme.secondaryContainer,
); surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
);
/// SnackBar theme /// SnackBar theme
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData( static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: scheme.inverseSurface, backgroundColor: scheme.inverseSurface,
@@ -200,40 +223,44 @@ class AppTheme {
); );
/// Progress indicator theme /// Progress indicator theme
static ProgressIndicatorThemeData _progressIndicatorTheme(ColorScheme scheme) => static ProgressIndicatorThemeData _progressIndicatorTheme(
ProgressIndicatorThemeData( ColorScheme scheme,
color: scheme.primary, ) => ProgressIndicatorThemeData(
linearTrackColor: scheme.surfaceContainerHighest, color: scheme.primary,
circularTrackColor: scheme.surfaceContainerHighest, linearTrackColor: scheme.surfaceContainerHighest,
); circularTrackColor: scheme.surfaceContainerHighest,
);
/// Switch theme /// Switch theme
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData( static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) { thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
return scheme.onPrimary; return scheme.onPrimary;
} }
return scheme.outline; return scheme.outline;
}), }),
trackColor: WidgetStateProperty.resolveWith((states) { trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
return scheme.primary; return scheme.primary;
} }
return scheme.surfaceContainerHighest; return scheme.surfaceContainerHighest;
}), }),
); thumbIcon: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return Icon(Icons.check, color: scheme.primary);
}
return null;
}),
);
/// Chip theme /// Chip theme
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surfaceContainerLow,
selectedColor: scheme.secondaryContainer, selectedColor: scheme.secondaryContainer,
); );
/// Divider theme /// Divider theme
static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData( static DividerThemeData _dividerTheme(ColorScheme scheme) =>
color: scheme.outlineVariant, DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
thickness: 1,
space: 1,
);
} }
+298 -9
View File
@@ -1,7 +1,234 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
/// Log entry with timestamp and level
class LogEntry {
final DateTime timestamp;
final String level;
final String tag;
final String message;
final String? error;
final bool isFromGo; // Track if this log came from Go backend
LogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
this.error,
this.isFromGo = false,
});
String get formattedTime {
final h = timestamp.hour.toString().padLeft(2, '0');
final m = timestamp.minute.toString().padLeft(2, '0');
final s = timestamp.second.toString().padLeft(2, '0');
final ms = timestamp.millisecond.toString().padLeft(3, '0');
return '$h:$m:$s.$ms';
}
@override
String toString() {
final errorPart = error != null ? ' | $error' : '';
final goPart = isFromGo ? ' [Go]' : '';
return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart';
}
}
/// Circular buffer for storing logs in memory
class LogBuffer extends ChangeNotifier {
static final LogBuffer _instance = LogBuffer._internal();
factory LogBuffer() => _instance;
LogBuffer._internal();
static const int maxEntries = 500;
final Queue<LogEntry> _entries = Queue<LogEntry>();
Timer? _goLogTimer;
int _lastGoLogIndex = 0;
/// Whether logging is enabled (controlled by settings)
/// User must enable "Detailed Logging" in settings to capture logs
static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) {
_loggingEnabled = value;
// Also notify Go backend about logging state
if (value) {
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
} else {
PlatformBridge.setGoLoggingEnabled(false).catchError((_) {});
}
}
List<LogEntry> get entries => _entries.toList();
int get length => _entries.length;
void add(LogEntry entry) {
// Skip adding if logging is disabled (except for errors which are always logged)
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return;
}
if (_entries.length >= maxEntries) {
_entries.removeFirst();
}
_entries.add(entry);
notifyListeners();
}
/// Start polling Go backend logs
void startGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
await _fetchGoLogs();
});
}
/// Stop polling Go backend logs
void stopGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = null;
}
/// Fetch logs from Go backend since last index
Future<void> _fetchGoLogs() async {
try {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
final logs = result['logs'] as List<dynamic>? ?? [];
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
for (final log in logs) {
final timestamp = log['timestamp'] as String? ?? '';
final level = log['level'] as String? ?? 'INFO';
final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? '';
// Parse timestamp (format: "15:04:05.000")
DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) {
try {
final parts = timestamp.split(':');
if (parts.length >= 3) {
final secParts = parts[2].split('.');
parsedTime = DateTime(
parsedTime.year, parsedTime.month, parsedTime.day,
int.parse(parts[0]), int.parse(parts[1]),
int.parse(secParts[0]),
secParts.length > 1 ? int.parse(secParts[1]) : 0,
);
}
} catch (_) {
// Use current time if parsing fails
}
}
add(LogEntry(
timestamp: parsedTime,
level: level,
tag: tag,
message: message,
isFromGo: true,
));
}
_lastGoLogIndex = nextIndex;
} catch (e) {
// Ignore errors - Go backend might not be ready
if (kDebugMode) {
debugPrint('Failed to fetch Go logs: $e');
}
}
}
void clear() {
_entries.clear();
_lastGoLogIndex = 0;
// Also clear Go backend logs
PlatformBridge.clearGoLogs().catchError((_) {});
notifyListeners();
}
String export() {
final buffer = StringBuffer();
buffer.writeln('SpotiFLAC Log Export');
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln('Entries: ${_entries.length}');
buffer.writeln('=' * 60);
buffer.writeln();
for (final entry in _entries) {
buffer.writeln(entry.toString());
}
return buffer.toString();
}
List<LogEntry> filter({String? level, String? tag, String? search}) {
return _entries.where((entry) {
if (level != null && level != 'ALL' && entry.level != level) {
return false;
}
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) {
return false;
}
if (search != null && search.isNotEmpty) {
final searchLower = search.toLowerCase();
return entry.message.toLowerCase().contains(searchLower) ||
entry.tag.toLowerCase().contains(searchLower) ||
(entry.error?.toLowerCase().contains(searchLower) ?? false);
}
return true;
}).toList();
}
}
/// Custom log output that writes to both console and buffer
class BufferedOutput extends LogOutput {
final String tag;
BufferedOutput(this.tag);
@override
void output(OutputEvent event) {
// Print to console in debug mode
if (kDebugMode) {
for (final line in event.lines) {
debugPrint(line);
}
}
// Add to buffer
final level = _levelToString(event.level);
final message = event.lines.join('\n');
LogBuffer().add(LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
));
}
String _levelToString(Level level) {
switch (level) {
case Level.debug:
return 'DEBUG';
case Level.info:
return 'INFO';
case Level.warning:
return 'WARN';
case Level.error:
return 'ERROR';
case Level.fatal:
return 'FATAL';
default:
return 'LOG';
}
}
}
/// Global logger instance for the app /// Global logger instance for the app
/// Uses pretty printer in debug mode for readable output
final log = Logger( final log = Logger(
printer: PrettyPrinter( printer: PrettyPrinter(
methodCount: 0, methodCount: 0,
@@ -15,14 +242,76 @@ final log = Logger(
); );
/// Logger with class/tag prefix for better traceability /// Logger with class/tag prefix for better traceability
/// Now also writes to LogBuffer for in-app viewing
/// Works in both debug and release mode
class AppLogger { class AppLogger {
final String _tag; final String _tag;
late final Logger? _logger;
AppLogger(this._tag);
AppLogger(this._tag) {
void d(String message) => log.d('[$_tag] $message'); // Only create Logger instance in debug mode
void i(String message) => log.i('[$_tag] $message'); // In release mode, we write directly to LogBuffer
void w(String message) => log.w('[$_tag] $message'); if (kDebugMode) {
void e(String message, [Object? error, StackTrace? stackTrace]) => _logger = Logger(
log.e('[$_tag] $message', error: error, stackTrace: stackTrace); printer: SimplePrinter(printTime: false, colors: false),
output: BufferedOutput(_tag),
level: Level.debug,
);
} else {
_logger = null;
}
}
void _addToBuffer(String level, String message, {String? error}) {
LogBuffer().add(LogEntry(
timestamp: DateTime.now(),
level: level,
tag: _tag,
message: message,
error: error,
));
}
void d(String message) {
if (kDebugMode) {
_logger?.d(message);
} else {
// In release mode, write directly to buffer
_addToBuffer('DEBUG', message);
}
}
void i(String message) {
if (kDebugMode) {
_logger?.i(message);
} else {
_addToBuffer('INFO', message);
}
}
void w(String message) {
if (kDebugMode) {
_logger?.w(message);
} else {
_addToBuffer('WARN', message);
}
}
void e(String message, [Object? error, StackTrace? stackTrace]) {
if (error != null) {
_addToBuffer('ERROR', message, error: error.toString());
if (kDebugMode) {
debugPrint('[$_tag] ERROR: $message | $error');
if (stackTrace != null) {
debugPrint(stackTrace.toString());
}
}
} else {
if (kDebugMode) {
_logger?.e(message);
} else {
_addToBuffer('ERROR', message);
}
}
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 2.1.7+45 version: 2.2.9+51
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 2.1.7+45 version: 2.2.9+51
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0