mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18bc079632 | |||
| 4091a9c499 | |||
| 9346f2d149 | |||
| 8ab52959e8 | |||
| bad95e99c8 | |||
| dbd7fd70be | |||
| 125d070cfe | |||
| 15acf181d1 | |||
| e049f9b868 | |||
| 6a886c5276 | |||
| 1ec190bfe7 | |||
| 7ca032b3f5 | |||
| 13b917d1a0 | |||
| 961072e2ac | |||
| 8a7815268b | |||
| c7e1ffd926 | |||
| 729ab01a5f | |||
| 0a16be4395 | |||
| 47cdb5564a | |||
| f7d5a24d17 | |||
| 8daff4d0a4 | |||
| a38d66fd41 | |||
| 0cab01780d | |||
| 4afc14dee8 | |||
| 00753ffe86 | |||
| 523b1edc44 | |||
| 4966a84614 | |||
| 9247a775fa | |||
| b185b51b31 | |||
| d98960d053 | |||
| d417743654 | |||
| c4bea124fb | |||
| c37410b5de | |||
| b90c94125c | |||
| efbf5d4c5b | |||
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| 93b4047143 | |||
| a6d488696b | |||
| 3dbd131e49 | |||
| 57cb575483 | |||
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 | |||
| 11e7034cec | |||
| f12c18d76b | |||
| 0da39a1b8b | |||
| f29fe5054c | |||
| c8c0164964 | |||
| 52dd657913 | |||
| c30f9fe412 | |||
| bea5dd1d4a | |||
| 8726a0858a |
@@ -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...
|
||||
@@ -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
|
||||
@@ -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
|
||||
description: Request new API features or capabilities for extension development
|
||||
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://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||
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...
|
||||
@@ -93,11 +93,12 @@ jobs:
|
||||
# Accept licenses
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK (required for gomobile)
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
|
||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||
# 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
|
||||
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
|
||||
run: |
|
||||
@@ -144,7 +145,7 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
@@ -344,6 +345,8 @@ jobs:
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
else
|
||||
echo "Found changelog content"
|
||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
@@ -382,20 +385,17 @@ jobs:
|
||||
### Downloads
|
||||
|
||||
#### Android
|
||||

|
||||

|
||||
|
||||
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
|
||||
#### iOS
|
||||

|
||||
|
||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
  
|
||||
FOOTER
|
||||
|
||||
echo "Release body:"
|
||||
|
||||
@@ -13,6 +13,9 @@ Thumbs.db
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Documentation (development only, published separately)
|
||||
docs/
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
|
||||
@@ -50,3 +53,9 @@ ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
+739
@@ -1,5 +1,744 @@
|
||||
# Changelog
|
||||
|
||||
## [3.0.0] - 2026-01-14
|
||||
|
||||
### Extension System (Major Feature)
|
||||
|
||||
SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more.
|
||||
|
||||
#### Extension Store
|
||||
|
||||
- Browse and install extensions directly from the app
|
||||
- New "Store" tab in bottom navigation
|
||||
- Browse by category: Metadata, Download, Utility, Lyrics, Integration
|
||||
- Search extensions by name, description, or tags
|
||||
- One-tap install, update, and uninstall
|
||||
- Offline cache for browsing without internet
|
||||
|
||||
#### Spotify Web Extension
|
||||
|
||||
- Available in Extension Store - install and enable in Settings > Extensions
|
||||
- Metadata provider using Spotify's internal web player API
|
||||
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
|
||||
- Useful when official Spotify API is rate-limited or unavailable
|
||||
|
||||
#### Extension Capabilities
|
||||
|
||||
- **Custom Search Providers**
|
||||
- **Custom URL Handlers**
|
||||
- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3)
|
||||
- **Post-Processing Hooks**: Extensions can process downloaded files
|
||||
- **Quality Options**: Extensions can define custom quality settings
|
||||
|
||||
#### Extension APIs
|
||||
|
||||
- Full HTTP support: GET, POST, PUT, DELETE, PATCH
|
||||
- Persistent cookie jar per extension
|
||||
- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams`
|
||||
- Storage API for persistent data
|
||||
- File API for file operations
|
||||
- HMAC-SHA1 utility for cryptographic operations
|
||||
|
||||
#### Security
|
||||
|
||||
- Sandboxed JavaScript runtime (goja)
|
||||
- Permission-based access control
|
||||
- Network domain whitelisting
|
||||
- Improved credential encryption with per-installation random salt
|
||||
|
||||
### Added
|
||||
|
||||
- **Album Folder Structure Setting**: Option to remove artist folder from album path
|
||||
|
||||
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
|
||||
- `Album Only`: `Albums/Album Name/`
|
||||
|
||||
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
|
||||
|
||||
- Based on `album_type` from Spotify/Deezer metadata
|
||||
- Toggle in Settings > Download > Separate Singles Folder
|
||||
|
||||
- **Parallel API Calls**: Download URL fetching now uses parallel requests
|
||||
- Tidal: All 8 APIs requested simultaneously, first success wins
|
||||
- Qobuz: Both APIs requested simultaneously, first success wins
|
||||
- Significantly reduces download URL fetch time
|
||||
|
||||
### UI/UX Improvements
|
||||
|
||||
- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters
|
||||
|
||||
- Swipe left/right to switch between filter tabs
|
||||
- Filter chips sync with swipe position
|
||||
- Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home
|
||||
- Natural gesture feel - drag connects to parent navigation
|
||||
|
||||
- **Improved File Open Intent**: Play button in History now correctly opens music players only
|
||||
- Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files
|
||||
- Prevents system from showing unrelated apps in the "Open with" dialog
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space
|
||||
|
||||
- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error
|
||||
|
||||
- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge
|
||||
|
||||
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
|
||||
|
||||
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
|
||||
|
||||
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
|
||||
|
||||
- Added `PopScope` with `canPop: true` to all settings pages
|
||||
- Changed navigation to use `PageRouteBuilder` with proper slide transition
|
||||
|
||||
- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode
|
||||
|
||||
- Made dialog scrollable with max height constraint
|
||||
|
||||
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names
|
||||
|
||||
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
|
||||
|
||||
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
|
||||
|
||||
- "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura"
|
||||
|
||||
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
|
||||
|
||||
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
|
||||
|
||||
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen
|
||||
|
||||
- Duplicate detection prefix now stripped before saving to history
|
||||
|
||||
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error
|
||||
|
||||
- Go backend now handles both array and object formats from extensions
|
||||
|
||||
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
||||
|
||||
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||
|
||||
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
||||
|
||||
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
|
||||
|
||||
- Now shows proper message: "Cannot write to folder, check storage permission"
|
||||
|
||||
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
|
||||
- Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Extension Manifest**: New `file` permission required for file operations
|
||||
```json
|
||||
"permissions": {
|
||||
"network": ["api.example.com"],
|
||||
"storage": true,
|
||||
"file": true
|
||||
}
|
||||
```
|
||||
|
||||
### Technical
|
||||
|
||||
- Go backend: Simplified parallel download result handling in Tidal/Qobuz
|
||||
- Go backend: Removed unused functions and fixed bit shifting warnings
|
||||
- Release workflow: Fixed duplicate `---` separator in release notes
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-beta.2] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Album Folder Structure Setting**: Option to remove artist folder from album path
|
||||
- New setting in Download Settings when "Separate Singles Folder" is enabled
|
||||
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
|
||||
- `Album Only`: `Albums/Album Name/`
|
||||
- Requested by user who prefers flat album organization
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings
|
||||
|
||||
- Added `PopScope` with `canPop: true` to all settings pages
|
||||
- Changed navigation to use `PageRouteBuilder` with proper slide transition
|
||||
- Fixes predictive back gesture conflict on devices with gesture navigation
|
||||
- Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail
|
||||
|
||||
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error
|
||||
|
||||
- Go backend now handles both array and object formats from extensions
|
||||
- Extensions returning `[{track}, {track}]` now work correctly
|
||||
- Extensions returning `{tracks: [...], total: N}` still work as before
|
||||
|
||||
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
|
||||
|
||||
- Added missing `spotifySize300` constant (300x300 size code)
|
||||
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
|
||||
- Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path
|
||||
- Go backend `cover.go` now directly replaces URL without HEAD verification
|
||||
|
||||
- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled
|
||||
|
||||
- `copyWith` in `AppSettings` couldn't set `searchProvider` to `null`
|
||||
- Added `clearSearchProvider` boolean parameter to properly clear the value
|
||||
- Settings menu now correctly switches back to default provider
|
||||
|
||||
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
|
||||
|
||||
- `_performSearch` now checks if extension is still enabled before calling custom search
|
||||
- Automatically falls back to Deezer/Spotify search if extension was disabled
|
||||
- Clears `searchProvider` setting if extension no longer available
|
||||
|
||||
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
||||
|
||||
- Added `mounted` check after async operation in `_initialize()`
|
||||
- Prevents crash when navigating away from Store tab during initialization
|
||||
|
||||
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download
|
||||
|
||||
- Duplicate detection was adding `EXISTS:` prefix to file paths
|
||||
- Prefix now stripped before saving to download history
|
||||
- Legacy history items with prefix are handled gracefully
|
||||
|
||||
- **History Error Badge**: Fixed error badge showing on history items even when file exists
|
||||
|
||||
- `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence
|
||||
- File open and delete operations also use cleaned path
|
||||
|
||||
- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions
|
||||
|
||||
- Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }`
|
||||
- Go backend `HandleURLWithExtensionJSON` now includes albums in artist response
|
||||
- Added `AlbumType` field to `ExtAlbumMetadata` struct
|
||||
|
||||
- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs
|
||||
|
||||
- Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items`
|
||||
- Logs correctly show "Fetched track: {title} by {artist}"
|
||||
|
||||
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names with different order
|
||||
|
||||
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
|
||||
- Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching
|
||||
- Handles Japanese name order (family name first) vs Western name order (given name first)
|
||||
|
||||
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
|
||||
|
||||
- "RADWIMPS feat. Toko Miura" now matches when Qobuz/Tidal only shows "Toko Miura"
|
||||
- Split artists by separators (`, `, `feat.`, `ft.`, `&`, `and`, `x`)
|
||||
- Match if ANY expected artist matches ANY found artist
|
||||
|
||||
- **Cover Download Logging**: Improved cover download logs for debugging
|
||||
- Shows original URL, upgrade steps, and final URL
|
||||
- Displays estimated resolution based on file size
|
||||
- Logs now appear in Settings > Logs via GoLog
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-beta.1] - 2026-01-13
|
||||
|
||||
### Security
|
||||
|
||||
- Improved extension sandbox security
|
||||
- Improved credential encryption with per-installation random salt
|
||||
|
||||
### Changed
|
||||
|
||||
- **Extension Manifest**: New `file` permission required for file operations
|
||||
```json
|
||||
"permissions": {
|
||||
"network": ["api.example.com"],
|
||||
"storage": true,
|
||||
"file": true
|
||||
}
|
||||
```
|
||||
Extensions that need to download files must declare `"file": true` in manifest.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Extension packages now preserve directory structure (subdirectories supported)
|
||||
- Back gesture freeze in settings pages on Android gesture navigation
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha.4] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Extension Store**: Browse and install extensions directly from the app
|
||||
|
||||
- New "Store" tab in bottom navigation
|
||||
- Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration)
|
||||
- Search extensions by name, description, or tags
|
||||
- One-tap install and update
|
||||
- Offline cache for browsing without internet
|
||||
- Extensions hosted at github.com/zarzet/SpotiFLAC-Extension
|
||||
|
||||
- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns
|
||||
|
||||
- Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc.
|
||||
- Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }`
|
||||
- Implement `handleUrl(url)` function in extension to parse and return track metadata
|
||||
- SpotiFLAC automatically routes matching URLs to the appropriate extension
|
||||
- Supports share intents and paste from clipboard
|
||||
|
||||
- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers
|
||||
|
||||
- Added `type: "artist"` handling in track_provider.dart
|
||||
- Navigate to artist screen with albums list from extension
|
||||
|
||||
- **HMAC-SHA1 Utility**: New `utils.hmacSHA1(key, message)` function for extensions
|
||||
- Enables TOTP generation and other cryptographic operations
|
||||
- Returns byte array for flexible use
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Extension Store Refresh**: Store tab now properly refreshes after uninstalling an extension
|
||||
- "Installed" badge correctly updates to "Install" button
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
|
||||
- Added Custom URL Handler section with examples
|
||||
- Added `handleUrl` function documentation
|
||||
- Added URL pattern examples for YouTube, SoundCloud, Bandcamp
|
||||
- Added `utils.hmacSHA1` documentation with TOTP example
|
||||
|
||||
### Extensions
|
||||
|
||||
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
|
||||
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
|
||||
- Search, album, playlist, track, and artist fetching
|
||||
- Available in Extension Store (3.0.0-alpha.4)
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha.3] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
|
||||
- Based on `album_type` from Spotify/Deezer metadata
|
||||
- Toggle in Settings > Download > Separate Singles Folder
|
||||
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
|
||||
- **Browser-like Polyfills**: New global APIs for easier library porting
|
||||
- `fetch()` - Browser-compatible HTTP API with `json()`, `text()`, `arrayBuffer()` methods
|
||||
- `atob()` / `btoa()` - Global Base64 encoding/decoding
|
||||
- `TextEncoder` / `TextDecoder` - UTF-8 text encoding classes
|
||||
- `URL` / `URLSearchParams` - URL parsing and manipulation classes
|
||||
- Makes porting browser libraries (like `youtubei.js`) much easier
|
||||
|
||||
### Performance
|
||||
|
||||
- **Parallel API Calls**: Download URL fetching now uses parallel requests
|
||||
- Tidal: All 8 APIs requested simultaneously, first success wins
|
||||
- Qobuz: Both APIs requested simultaneously, first success wins
|
||||
- Significantly reduces download URL fetch time
|
||||
|
||||
### 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
|
||||
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
|
||||
- Now checks if extension is still enabled before calling custom search
|
||||
- Auto-resets search provider to default if extension was disabled
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha.2] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
|
||||
- `http.put(url, body, headers)` - PUT requests
|
||||
- `http.delete(url, headers)` - DELETE requests
|
||||
- `http.patch(url, body, headers)` - PATCH requests
|
||||
- `http.clearCookies()` - Clear all cookies for the extension
|
||||
- **Persistent Cookie Jar**: Each extension now has its own cookie jar
|
||||
- Cookies automatically stored from `Set-Cookie` headers
|
||||
- Cookies automatically sent with subsequent requests to same domain
|
||||
- Useful for APIs requiring session cookies (YouTube, etc.)
|
||||
- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers
|
||||
- `Set-Cookie` and other headers with multiple values returned as arrays
|
||||
- Single-value headers still returned as strings for convenience
|
||||
- **Generic HTTP Request Method**: New `http.request()` for full HTTP control
|
||||
- Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
|
||||
- Single options object for cleaner API: `http.request(url, { method, body, headers })`
|
||||
- **Response Helper Properties**: HTTP responses now include convenience properties
|
||||
- `response.ok` - true if status code is 2xx
|
||||
- `response.status` - alias for `statusCode`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected
|
||||
- Previously, extension-provided User-Agent was overwritten
|
||||
- Now only sets default User-Agent if extension doesn't provide one
|
||||
- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON
|
||||
- Previously, passing an object as body resulted in `[object Object]`
|
||||
- Now objects and arrays are automatically JSON.stringify'd
|
||||
- String bodies still work as before (no double-encoding)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
|
||||
- Added complete HTTP API documentation with all methods
|
||||
- Added Cookie Jar documentation
|
||||
- Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs
|
||||
- Added YouTube Music / Innertube API example with custom User-Agent
|
||||
- Added common domain lists for YouTube, SoundCloud, Bandcamp
|
||||
- Improved HTTP API documentation with response properties
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha.1] - 2026-01-11
|
||||
|
||||
#### Extension System
|
||||
|
||||
- **Custom Search Providers**: Extensions can now provide custom search functionality
|
||||
- YouTube, SoundCloud, and other platforms via extensions
|
||||
- Custom search placeholder text per extension
|
||||
- Configurable thumbnail aspect ratios (square, wide, portrait)
|
||||
- **Extension Upgrade System**: Upgrade extensions without losing data
|
||||
- Preserves extension settings and cached data during upgrades
|
||||
- Version comparison prevents downgrades
|
||||
- Auto-detects upgrades when installing same extension
|
||||
- **Custom Thumbnail Ratios**: Extensions can specify thumbnail display format
|
||||
- `"square"` (1:1) - Album art style (default)
|
||||
- `"wide"` (16:9) - YouTube/video style
|
||||
- `"portrait"` (2:3) - Poster style
|
||||
- Custom width/height override available
|
||||
|
||||
### Added
|
||||
|
||||
- **Track Source Tracking**: Tracks now remember which extension provided them
|
||||
- `Track.source` field stores extension ID
|
||||
- `TrackState.searchExtensionId` for current search context
|
||||
- Enables extension-specific UI customization
|
||||
- **Extension Upgrade API**: New methods for extension management
|
||||
- `upgradeExtension(filePath)` - Upgrade existing extension
|
||||
- `checkExtensionUpgrade(filePath)` - Check if file is an upgrade
|
||||
- `RemoveExtensionByID` - Remove extension by ID
|
||||
- **iOS Extension Support**: Added missing iOS method handlers
|
||||
- `upgradeExtension` - Upgrade extension from file
|
||||
- `checkExtensionUpgrade` - Check upgrade compatibility
|
||||
- **Extension Documentation**: Comprehensive extension development guide
|
||||
- Thumbnail ratio customization documentation
|
||||
- Extension upgrade workflow documentation
|
||||
- New troubleshooting entries for common issues
|
||||
|
||||
### Changed
|
||||
|
||||
- **Version Bump**: 2.2.7 → 3.0.0-alpha.1 (major version for extension system)
|
||||
- **Build Number**: 49 → 50
|
||||
- **Extension Manager**: Improved upgrade detection in `LoadExtensionFromFile`
|
||||
- Auto-detects if installing same extension with higher version
|
||||
- Calls `UpgradeExtension` automatically for seamless upgrades
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Extension `registerExtension`**: Fixed global `extension` variable not being set
|
||||
- Extensions can now access their own functions via `extension.functionName()`
|
||||
- Required for `customSearch` and other provider functions
|
||||
- **Custom Search Empty Results**: Fixed error when extension returns null
|
||||
- Now returns empty array instead of error
|
||||
- Prevents crash when no results found
|
||||
- **Mutex Crash on Upgrade**: Fixed "Unlock of unlocked RWMutex" crash
|
||||
- Removed `defer m.mu.Unlock()` when manual unlock is used
|
||||
- Proper lock handling in upgrade flow
|
||||
- **Duplicate Error Messages**: Fixed extension install errors showing twice
|
||||
- Added `clearError()` method to extension provider
|
||||
- Improved PlatformException parsing to remove "null, null" artifacts
|
||||
- **Extension Images Field**: Fixed thumbnails not showing in search results
|
||||
- Added `Images` field to `ExtTrackMetadata` struct
|
||||
- Renamed `GetCoverURL` to `ResolvedCoverURL` (gomobile conflict)
|
||||
|
||||
### Technical
|
||||
|
||||
- **Go Backend Changes**:
|
||||
- `go_backend/extension_manager.go`: Added `compareVersions()`, `UpgradeExtension()`, `CheckExtensionUpgradeJSON()`
|
||||
- `go_backend/extension_providers.go`: Added `Images` field, `ResolvedCoverURL()` method
|
||||
- `go_backend/extension_manifest.go`: Added `ThumbnailRatio`, `ThumbnailWidth`, `ThumbnailHeight` to `SearchBehaviorConfig`
|
||||
- `go_backend/exports.go`: Added `RemoveExtensionByID`, `UpgradeExtensionFromPath`, `CheckExtensionUpgradeFromPath`
|
||||
- **Flutter Changes**:
|
||||
- `lib/models/track.dart`: Added `source` field
|
||||
- `lib/models/track.g.dart`: Updated for `source` field
|
||||
- `lib/providers/track_provider.dart`: Added `searchExtensionId`, updated `_parseSearchTrack` with source parameter
|
||||
- `lib/providers/extension_provider.dart`: Added `SearchBehavior.getThumbnailSize()`, `clearError()`
|
||||
- `lib/screens/home_tab.dart`: Dynamic thumbnail size based on extension config
|
||||
- `lib/screens/settings/extensions_page.dart`: Improved error handling
|
||||
- `lib/services/platform_bridge.dart`: Added `upgradeExtension()`, `checkExtensionUpgrade()`, `removeExtension()`
|
||||
- **iOS Changes**:
|
||||
- `ios/Runner/AppDelegate.swift`: Added `upgradeExtension`, `checkExtensionUpgrade` handlers
|
||||
- **Android Changes**:
|
||||
- `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/ca16289599f71b8e50d3726a8c64a202ea922a1893bcf21b9eca1a050736f1f5/)
|
||||
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -40,6 +40,20 @@ To use Spotify as your search source without hitting rate limits:
|
||||
4. Enter your Client ID and Secret
|
||||
5. Change **Search Source** to Spotify
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
@@ -49,8 +63,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
|
||||
@@ -46,7 +46,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
|
||||
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
|
||||
/**
|
||||
* Foreground service to keep downloads running when app is in background.
|
||||
* 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() {
|
||||
|
||||
@@ -106,6 +109,19 @@ class DownloadService : Service() {
|
||||
|
||||
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() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
|
||||
@@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"readFileMetadata" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.readFileMetadata(filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"startDownloadService" -> {
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
@@ -211,6 +218,12 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"hasSpotifyCredentials" -> {
|
||||
val hasCredentials = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkSpotifyCredentials()
|
||||
}
|
||||
result.success(hasCredentials)
|
||||
}
|
||||
"preWarmTrackCache" -> {
|
||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -277,6 +290,346 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Extension System methods
|
||||
"initExtensionSystem" -> {
|
||||
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.initExtensionSystem(extensionsDir, dataDir)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"loadExtensionsFromDir" -> {
|
||||
val dirPath = call.argument<String>("dir_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.loadExtensionsFromDir(dirPath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"loadExtensionFromPath" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.loadExtensionFromPath(filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"unloadExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.unloadExtensionByID(extensionId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"removeExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.removeExtensionByID(extensionId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"upgradeExtension" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.upgradeExtensionFromPath(filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkExtensionUpgrade" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkExtensionUpgradeFromPath(filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getInstalledExtensions" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getInstalledExtensions()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setExtensionEnabled" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setExtensionEnabledByID(extensionId, enabled)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setProviderPriority" -> {
|
||||
val priorityJson = call.argument<String>("priority") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setProviderPriorityJSON(priorityJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getProviderPriority" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getProviderPriorityJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setMetadataProviderPriority" -> {
|
||||
val priorityJson = call.argument<String>("priority") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setMetadataProviderPriorityJSON(priorityJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getMetadataProviderPriority" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getMetadataProviderPriorityJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getExtensionSettings" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionSettingsJSON(extensionId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setExtensionSettings" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val settingsJson = call.argument<String>("settings") ?: "{}"
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setExtensionSettingsJSON(extensionId, settingsJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"searchTracksWithExtensions" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 20
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchTracksWithExtensionsJSON(query, limit.toLong())
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadWithExtensions" -> {
|
||||
val requestJson = call.arguments as String
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.downloadWithExtensionsJSON(requestJson)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"removeExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.removeExtensionByID(extensionId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"cleanupExtensions" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cleanupExtensions()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension Auth API methods
|
||||
"getExtensionPendingAuth" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionPendingAuthJSON(extensionId)
|
||||
}
|
||||
if (response.isNullOrEmpty()) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.success(response)
|
||||
}
|
||||
}
|
||||
"setExtensionAuthCode" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val authCode = call.argument<String>("auth_code") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setExtensionAuthCodeByID(extensionId, authCode)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setExtensionTokens" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val accessToken = call.argument<String>("access_token") ?: ""
|
||||
val refreshToken = call.argument<String>("refresh_token") ?: ""
|
||||
val expiresIn = call.argument<Int>("expires_in") ?: 0
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setExtensionTokensByID(extensionId, accessToken, refreshToken, expiresIn.toLong())
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"clearExtensionPendingAuth" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.clearExtensionPendingAuthByID(extensionId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"isExtensionAuthenticated" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val isAuth = withContext(Dispatchers.IO) {
|
||||
Gobackend.isExtensionAuthenticatedByID(extensionId)
|
||||
}
|
||||
result.success(isAuth)
|
||||
}
|
||||
"getAllPendingAuthRequests" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllPendingAuthRequestsJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension FFmpeg API
|
||||
"getPendingFFmpegCommand" -> {
|
||||
val commandId = call.argument<String>("command_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getPendingFFmpegCommandJSON(commandId)
|
||||
}
|
||||
if (response.isNullOrEmpty()) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.success(response)
|
||||
}
|
||||
}
|
||||
"setFFmpegCommandResult" -> {
|
||||
val commandId = call.argument<String>("command_id") ?: ""
|
||||
val success = call.argument<Boolean>("success") ?: false
|
||||
val output = call.argument<String>("output") ?: ""
|
||||
val error = call.argument<String>("error") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setFFmpegCommandResultByID(commandId, success, output, error)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getAllPendingFFmpegCommands" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllPendingFFmpegCommandsJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Custom Search API
|
||||
"customSearchWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val optionsJson = call.argument<String>("options") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getSearchProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSearchProvidersJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension URL Handler API
|
||||
"handleURLWithExtension" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.handleURLWithExtensionJSON(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"findURLHandler" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.findURLHandlerJSON(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getURLHandlers" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getURLHandlersJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Post-Processing API
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.runPostProcessingJSON(filePath, metadataJson)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getPostProcessingProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getPostProcessingProvidersJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Store
|
||||
"initExtensionStore" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.initExtensionStoreJSON(cacheDir)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getStoreExtensions" -> {
|
||||
val forceRefresh = call.argument<Boolean>("force_refresh") ?: false
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getStoreExtensionsJSON(forceRefresh)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchStoreExtensions" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val category = call.argument<String>("category") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchStoreExtensionsJSON(query, category)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getStoreCategories" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getStoreCategoriesJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadStoreExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val destDir = call.argument<String>("dest_dir") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.downloadStoreExtensionJSON(extensionId, destDir)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"clearStoreCache" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.clearStoreCacheJSON()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} 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 |
+81
-49
@@ -18,7 +18,7 @@ import (
|
||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||
type AmazonDownloader struct {
|
||||
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
|
||||
apiCallCount int // Rate limiting: counter per minute
|
||||
apiCallResetTime time.Time // Rate limiting: reset time
|
||||
@@ -52,46 +52,46 @@ type DoubleDoubleStatusResponse struct {
|
||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||
foundASCII := amazonIsASCIIString(foundArtist)
|
||||
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 false
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
defer amazonRateLimitMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
|
||||
// Reset counter every minute
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
@@ -135,7 +135,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
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)
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = time.Now()
|
||||
@@ -148,7 +148,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -170,19 +170,18 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||
return apis
|
||||
}
|
||||
|
||||
|
||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||
// This uses submit → poll → download mechanism
|
||||
// Internal function - not exported to gomobile
|
||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) {
|
||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
|
||||
var lastError error
|
||||
|
||||
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
|
||||
// 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
|
||||
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())
|
||||
|
||||
fmt.Println("[Amazon] Submitting download request...")
|
||||
|
||||
|
||||
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
||||
var resp *http.Response
|
||||
maxRetries := 3
|
||||
@@ -217,7 +216,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
resp.Body.Close()
|
||||
if retry < maxRetries-1 {
|
||||
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)
|
||||
continue
|
||||
}
|
||||
@@ -256,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
||||
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
@@ -311,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
trackName := status.Current.Name
|
||||
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
|
||||
|
||||
} 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)
|
||||
}
|
||||
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Initialize item progress (required for all downloads)
|
||||
@@ -434,6 +432,7 @@ type AmazonDownloadResult struct {
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
@@ -450,12 +449,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
|
||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
// Extract Deezer ID and use Deezer-based lookup
|
||||
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)
|
||||
} else if req.SpotifyID != "" {
|
||||
// Use Spotify ID
|
||||
@@ -463,7 +462,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
|
||||
if err != nil {
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
@@ -543,19 +542,38 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
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)
|
||||
// But preserve track/disc numbers from file if they were better
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TrackNumber: actualTrackNum,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
}
|
||||
|
||||
@@ -563,7 +581,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
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 {
|
||||
@@ -572,9 +590,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
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 {
|
||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
}
|
||||
@@ -583,36 +601,50 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
|
||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||
|
||||
|
||||
// Read actual quality from the downloaded FLAC file
|
||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
// Return 0 to indicate unknown quality
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: 0,
|
||||
SampleRate: 0,
|
||||
}, nil
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
// Read metadata from file AFTER embedding to get accurate values
|
||||
// This ensures we return what's actually in the file
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
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
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
|
||||
bitDepth := 0
|
||||
sampleRate := 0
|
||||
if err == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: quality.BitDepth,
|
||||
SampleRate: quality.SampleRate,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+48
-26
@@ -9,10 +9,20 @@ import (
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
)
|
||||
|
||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||
// Same logic as PC version for consistency
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||
// This avoids file permission issues on Android
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
@@ -20,17 +30,27 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
|
||||
GoLog("[Cover] Original URL: %s", coverURL)
|
||||
|
||||
// Upgrade to max quality if requested
|
||||
downloadURL := coverURL
|
||||
// First upgrade small (300) to medium (640) - always do this
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||
}
|
||||
|
||||
// Then upgrade to max quality if requested
|
||||
if maxQuality {
|
||||
downloadURL = upgradeToMaxQuality(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
|
||||
} else {
|
||||
GoLog("[Cover] Max resolution not available, using 640x640")
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
||||
// Create request with User-Agent (required by Spotify CDN)
|
||||
@@ -54,12 +74,25 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
|
||||
// Calculate approximate resolution from file size
|
||||
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
|
||||
sizeKB := len(data) / 1024
|
||||
var resolution string
|
||||
if sizeKB > 200 {
|
||||
resolution = "~2000x2000 (hi-res)"
|
||||
} else if sizeKB > 50 {
|
||||
resolution = "~640x640"
|
||||
} else {
|
||||
resolution = "~300x300"
|
||||
}
|
||||
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
||||
// Uses same logic as PC version - replaces 640x640 size code with max resolution
|
||||
// Same logic as PC version - directly replaces 640x640 size code with max resolution
|
||||
// No HEAD verification needed - Spotify CDN always serves max resolution if available
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify image URLs can be upgraded by changing the size parameter
|
||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||
@@ -67,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
|
||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
// Try max resolution first
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
|
||||
// Verify max resolution URL is available
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
req, err := http.NewRequest("HEAD", maxURL, nil)
|
||||
if err == nil {
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
@@ -93,9 +112,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Always upgrade small to medium first
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
return upgradeToMaxQuality(imageURL)
|
||||
result = upgradeToMaxQuality(result)
|
||||
}
|
||||
|
||||
return imageURL
|
||||
return result
|
||||
}
|
||||
|
||||
+106
-65
@@ -19,9 +19,9 @@ const (
|
||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||
|
||||
|
||||
deezerCacheTTL = 10 * time.Minute
|
||||
|
||||
|
||||
// Parallel ISRC fetching settings
|
||||
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
)
|
||||
@@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient {
|
||||
|
||||
// Deezer API response types
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"` // in seconds
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"` // in seconds
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
}
|
||||
|
||||
type deezerArtist struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbFan int `json:"nb_fan"`
|
||||
NbFan int `json:"nb_fan"`
|
||||
}
|
||||
|
||||
type deezerAlbumSimple struct {
|
||||
@@ -90,6 +90,7 @@ type deezerAlbumSimple struct {
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||
}
|
||||
|
||||
// ... (skip other structs as they are fine/unchanged) ...
|
||||
|
||||
// ... (in convertTrack) ...
|
||||
@@ -113,7 +114,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
if albumImage == "" {
|
||||
albumImage = track.Album.Cover
|
||||
}
|
||||
|
||||
|
||||
// Try to find release date
|
||||
releaseDate := track.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
@@ -137,17 +138,18 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
}
|
||||
|
||||
type deezerAlbumFull struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
Tracks struct {
|
||||
Tracks struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
@@ -164,17 +166,17 @@ type deezerArtistFull struct {
|
||||
}
|
||||
|
||||
type deezerPlaylistFull struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Picture string `json:"picture"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
Creator struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
Creator struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"creator"`
|
||||
Tracks struct {
|
||||
Tracks struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
@@ -182,11 +184,14 @@ type deezerPlaylistFull struct {
|
||||
// SearchAll searches for tracks and artists on Deezer
|
||||
// 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) {
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||
|
||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||
return entry.data.(*SearchAllResult), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
@@ -198,13 +203,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
|
||||
// Search tracks - NO ISRC fetch for performance
|
||||
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 {
|
||||
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 {
|
||||
GoLog("[Deezer] Track search failed: %v\n", 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 {
|
||||
// Convert directly without fetching ISRC - much faster
|
||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||
@@ -212,21 +232,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
|
||||
// Search artists
|
||||
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 {
|
||||
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 {
|
||||
for _, artist := range artistResp.Data {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||
Name: artist.Name,
|
||||
Images: c.getBestArtistImage(artist),
|
||||
Followers: artist.NbFan,
|
||||
Popularity: 0,
|
||||
})
|
||||
if artistResp.Error != nil {
|
||||
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||
} else {
|
||||
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||
for _, artist := range artistResp.Data {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
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
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
@@ -241,7 +277,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
// GetTrack fetches a single track by Deezer ID
|
||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||
|
||||
|
||||
var track deezerTrack
|
||||
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||
return nil, err
|
||||
@@ -263,7 +299,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||
|
||||
|
||||
var album deezerAlbumFull
|
||||
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||
return nil, err
|
||||
@@ -291,6 +327,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
||||
albumType := album.RecordType
|
||||
if albumType == "compile" {
|
||||
albumType = "compilation"
|
||||
}
|
||||
|
||||
for _, track := range album.Tracks.Data {
|
||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||
isrc := isrcMap[trackIDStr]
|
||||
@@ -310,6 +352,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
AlbumType: albumType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -375,7 +418,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
if albumType == "compile" {
|
||||
albumType = "compilation"
|
||||
}
|
||||
|
||||
|
||||
coverURL := album.CoverXL
|
||||
if coverURL == "" {
|
||||
coverURL = album.CoverBig
|
||||
@@ -418,7 +461,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
// ISRC is fetched in parallel for better performance
|
||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||
|
||||
|
||||
var playlist deezerPlaylistFull
|
||||
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||
return nil, err
|
||||
@@ -482,7 +525,7 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
||||
// Use direct ISRC endpoint (API 2.0)
|
||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||
|
||||
|
||||
var track deezerTrack
|
||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||
// Fallback to search if direct endpoint fails
|
||||
@@ -522,7 +565,7 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
|
||||
|
||||
// First, check cache for existing ISRCs
|
||||
var tracksToFetch []deezerTrack
|
||||
c.cacheMu.RLock()
|
||||
@@ -535,20 +578,20 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
}
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
|
||||
if len(tracksToFetch) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for _, track := range tracksToFetch {
|
||||
wg.Add(1)
|
||||
go func(t deezerTrack) {
|
||||
defer wg.Done()
|
||||
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
@@ -556,24 +599,24 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
trackIDStr := fmt.Sprintf("%d", t.ID)
|
||||
fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
|
||||
if err != nil || fullTrack == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Store in result and cache
|
||||
resultMu.Lock()
|
||||
result[trackIDStr] = fullTrack.ISRC
|
||||
resultMu.Unlock()
|
||||
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||
c.cacheMu.Unlock()
|
||||
}(track)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
@@ -588,23 +631,21 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
||||
return isrc, nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
|
||||
// Fetch from API
|
||||
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
// Cache the result
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackID] = fullTrack.ISRC
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
|
||||
return fullTrack.ISRC, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||
if artist.PictureXL != "" {
|
||||
return artist.PictureXL
|
||||
@@ -687,7 +728,7 @@ func parseDeezerURL(input string) (string, string, error) {
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
|
||||
|
||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||
parts = parts[1:]
|
||||
|
||||
+1047
-121
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
// Package gobackend provides extension manifest parsing and validation
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtensionType represents the type of extension
|
||||
type ExtensionType string
|
||||
|
||||
const (
|
||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||
)
|
||||
|
||||
// SettingType represents the type of a setting field
|
||||
type SettingType string
|
||||
|
||||
const (
|
||||
SettingTypeString SettingType = "string"
|
||||
SettingTypeNumber SettingType = "number"
|
||||
SettingTypeBool SettingType = "boolean"
|
||||
SettingTypeSelect SettingType = "select"
|
||||
)
|
||||
|
||||
// ExtensionPermissions defines what resources an extension can access
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"` // List of allowed domains
|
||||
Storage bool `json:"storage"` // Whether extension can use storage API
|
||||
File bool `json:"file"` // Whether extension can use file API
|
||||
}
|
||||
|
||||
// ExtensionSetting defines a configurable setting for an extension
|
||||
type ExtensionSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"` // For select type
|
||||
}
|
||||
|
||||
// QualityOption represents a quality option for download providers
|
||||
type QualityOption struct {
|
||||
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
|
||||
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
|
||||
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
|
||||
}
|
||||
|
||||
// QualitySpecificSetting represents a setting that's specific to a quality option
|
||||
type QualitySpecificSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"` // For select type
|
||||
}
|
||||
|
||||
// SearchBehaviorConfig defines custom search behavior for an extension
|
||||
type SearchBehaviorConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension provides custom search
|
||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
||||
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
||||
Icon string `json:"icon,omitempty"` // Icon for search tab
|
||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
||||
}
|
||||
|
||||
// URLHandlerConfig defines custom URL handling for an extension
|
||||
type URLHandlerConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
||||
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
||||
}
|
||||
|
||||
// TrackMatchingConfig defines custom track matching behavior
|
||||
type TrackMatchingConfig struct {
|
||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
||||
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
|
||||
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
|
||||
}
|
||||
|
||||
// PostProcessingHook defines a post-processing hook
|
||||
type PostProcessingHook struct {
|
||||
ID string `json:"id"` // Unique identifier
|
||||
Name string `json:"name"` // Display name
|
||||
Description string `json:"description,omitempty"` // Description
|
||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
|
||||
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
|
||||
}
|
||||
|
||||
// PostProcessingConfig defines post-processing capabilities
|
||||
type PostProcessingConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension provides post-processing
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
|
||||
}
|
||||
|
||||
// ExtensionManifest represents the manifest.json of an extension
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
}
|
||||
|
||||
// ManifestValidationError represents a validation error in the manifest
|
||||
type ManifestValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ManifestValidationError) Error() string {
|
||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// ParseManifest parses and validates a manifest from JSON bytes
|
||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
var manifest ExtensionManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// Validate checks if the manifest has all required fields and valid values
|
||||
func (m *ExtensionManifest) Validate() error {
|
||||
// Check required fields
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Version) == "" {
|
||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Author) == "" {
|
||||
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Description) == "" {
|
||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||
}
|
||||
|
||||
if len(m.Types) == 0 {
|
||||
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||
}
|
||||
|
||||
// Validate extension types
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||
return &ManifestValidationError{
|
||||
Field: "type",
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings if present
|
||||
for i, setting := range m.Settings {
|
||||
if strings.TrimSpace(setting.Key) == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].key", i),
|
||||
Message: "setting key is required",
|
||||
}
|
||||
}
|
||||
|
||||
if setting.Type == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].type", i),
|
||||
Message: "setting type is required",
|
||||
}
|
||||
}
|
||||
|
||||
// Validate setting type
|
||||
validTypes := map[SettingType]bool{
|
||||
SettingTypeString: true,
|
||||
SettingTypeNumber: true,
|
||||
SettingTypeBool: true,
|
||||
SettingTypeSelect: true,
|
||||
}
|
||||
if !validTypes[setting.Type] {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].type", i),
|
||||
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].options", i),
|
||||
Message: "select type requires options",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasType checks if the extension has a specific type
|
||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
for _, et := range m.Types {
|
||||
if et == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsMetadataProvider returns true if extension provides metadata
|
||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||
return m.HasType(ExtensionTypeMetadataProvider)
|
||||
}
|
||||
|
||||
// IsDownloadProvider returns true if extension provides downloads
|
||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||
return m.HasType(ExtensionTypeDownloadProvider)
|
||||
}
|
||||
|
||||
// IsDomainAllowed checks if a domain is in the allowed network permissions
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||
if allowed == domain {
|
||||
return true
|
||||
}
|
||||
// Support wildcard subdomains (e.g., *.example.com)
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := allowed[1:] // Remove the *
|
||||
if strings.HasSuffix(domain, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasCustomSearch returns true if extension provides custom search
|
||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||
}
|
||||
|
||||
// HasCustomMatching returns true if extension provides custom track matching
|
||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||
}
|
||||
|
||||
// HasPostProcessing returns true if extension provides post-processing
|
||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||
}
|
||||
|
||||
// HasURLHandler returns true if extension handles custom URLs
|
||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||
}
|
||||
|
||||
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
if !m.HasURLHandler() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse URL to get host
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
// Check if URL contains the pattern (host match)
|
||||
if strings.Contains(urlStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPostProcessingHooks returns all post-processing hooks
|
||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
if m.PostProcessing == nil {
|
||||
return nil
|
||||
}
|
||||
return m.PostProcessing.Hooks
|
||||
}
|
||||
|
||||
// ToJSON serializes the manifest to JSON
|
||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
// Package gobackend provides extension runtime with sandboxed execution
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// Default timeout for JS execution (30 seconds)
|
||||
const DefaultJSTimeout = 30 * time.Second
|
||||
|
||||
// Global auth state for extensions (stores pending auth codes)
|
||||
var (
|
||||
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||
extensionAuthStateMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ExtensionAuthState holds auth state for an extension
|
||||
type ExtensionAuthState struct {
|
||||
PendingAuthURL string
|
||||
AuthCode string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
IsAuthenticated bool
|
||||
// PKCE support
|
||||
PKCEVerifier string
|
||||
PKCEChallenge string
|
||||
}
|
||||
|
||||
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
|
||||
type PendingAuthRequest struct {
|
||||
ExtensionID string
|
||||
AuthURL string
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
// Global pending auth requests (Flutter polls this)
|
||||
var (
|
||||
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||
pendingAuthRequestsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
|
||||
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||
pendingAuthRequestsMu.RLock()
|
||||
defer pendingAuthRequestsMu.RUnlock()
|
||||
return pendingAuthRequests[extensionID]
|
||||
}
|
||||
|
||||
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
|
||||
func ClearPendingAuthRequest(extensionID string) {
|
||||
pendingAuthRequestsMu.Lock()
|
||||
defer pendingAuthRequestsMu.Unlock()
|
||||
delete(pendingAuthRequests, extensionID)
|
||||
}
|
||||
|
||||
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
|
||||
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[extensionID] = state
|
||||
}
|
||||
state.AuthCode = authCode
|
||||
}
|
||||
|
||||
// SetExtensionTokens sets access/refresh tokens for an extension
|
||||
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[extensionID] = state
|
||||
}
|
||||
state.AccessToken = accessToken
|
||||
state.RefreshToken = refreshToken
|
||||
state.ExpiresAt = expiresAt
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
// ExtensionRuntime provides sandboxed APIs for extensions
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// NewExtensionRuntime creates a new runtime for an extension
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
// Create a cookie jar for this extension
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &ExtensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
}
|
||||
|
||||
// Create HTTP client with redirect validation to prevent SSRF via open redirect
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Validate redirect target domain against allowed domains
|
||||
domain := req.URL.Hostname()
|
||||
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
}
|
||||
// Also block redirects to private/local networks (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
}
|
||||
// Default redirect limit (10)
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
|
||||
type RedirectBlockedError struct {
|
||||
Domain string
|
||||
IsPrivate bool
|
||||
}
|
||||
|
||||
func (e *RedirectBlockedError) Error() string {
|
||||
if e.IsPrivate {
|
||||
return "redirect blocked: private/local network access denied"
|
||||
}
|
||||
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
||||
}
|
||||
|
||||
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||
func isPrivateIP(host string) bool {
|
||||
// Block common private network patterns
|
||||
// This is a simple check - for production, consider DNS resolution
|
||||
privatePatterns := []string{
|
||||
"localhost",
|
||||
"127.",
|
||||
"10.",
|
||||
"172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.",
|
||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.",
|
||||
"169.254.", // Link-local
|
||||
"::1", // IPv6 localhost
|
||||
"fc00:", // IPv6 private
|
||||
"fe80:", // IPv6 link-local
|
||||
}
|
||||
|
||||
hostLower := host
|
||||
for _, pattern := range privatePatterns {
|
||||
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also block .local domains
|
||||
if len(host) > 6 && host[len(host)-6:] == ".local" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// simpleCookieJar is a simple in-memory cookie jar
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||
return &simpleCookieJar{
|
||||
cookies: make(map[string][]*http.Cookie),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
key := u.Host
|
||||
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
// SetSettings updates the runtime settings
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
// RegisterAPIs registers all sandboxed APIs to the Goja VM
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
// HTTP client (sandboxed to allowed domains)
|
||||
httpObj := vm.NewObject()
|
||||
httpObj.Set("get", r.httpGet)
|
||||
httpObj.Set("post", r.httpPost)
|
||||
httpObj.Set("put", r.httpPut)
|
||||
httpObj.Set("delete", r.httpDelete)
|
||||
httpObj.Set("patch", r.httpPatch)
|
||||
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
// Storage API
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
storageObj.Set("remove", r.storageRemove)
|
||||
vm.Set("storage", storageObj)
|
||||
|
||||
// Secure Credentials API (encrypted storage for sensitive data)
|
||||
credentialsObj := vm.NewObject()
|
||||
credentialsObj.Set("store", r.credentialsStore)
|
||||
credentialsObj.Set("get", r.credentialsGet)
|
||||
credentialsObj.Set("remove", r.credentialsRemove)
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
|
||||
// Auth API (for OAuth and other auth flows)
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
authObj.Set("setAuthCode", r.authSetCode)
|
||||
authObj.Set("clearAuth", r.authClear)
|
||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||
authObj.Set("getTokens", r.authGetTokens)
|
||||
// PKCE support
|
||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||
authObj.Set("getPKCE", r.authGetPKCE)
|
||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
// File operations (sandboxed)
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
// FFmpeg API (for post-processing)
|
||||
ffmpegObj := vm.NewObject()
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
|
||||
// Track matching API
|
||||
matchingObj := vm.NewObject()
|
||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||
vm.Set("matching", matchingObj)
|
||||
|
||||
// Utilities
|
||||
utilsObj := vm.NewObject()
|
||||
utilsObj.Set("base64Encode", r.base64Encode)
|
||||
utilsObj.Set("base64Decode", r.base64Decode)
|
||||
utilsObj.Set("md5", r.md5Hash)
|
||||
utilsObj.Set("sha256", r.sha256Hash)
|
||||
utilsObj.Set("hmacSHA256", r.hmacSHA256)
|
||||
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
|
||||
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||
utilsObj.Set("parseJSON", r.parseJSON)
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
// Crypto utilities for developers
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
// Log object (already set in extension_manager.go, but we can enhance it)
|
||||
logObj := vm.NewObject()
|
||||
logObj.Set("debug", r.logDebug)
|
||||
logObj.Set("info", r.logInfo)
|
||||
logObj.Set("warn", r.logWarn)
|
||||
logObj.Set("error", r.logError)
|
||||
vm.Set("log", logObj)
|
||||
|
||||
// Go backend functions
|
||||
gobackendObj := vm.NewObject()
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These make porting browser/Node.js libraries easier
|
||||
|
||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
// Global atob/btoa - Base64 encoding (browser-compatible)
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
vm.Set("btoa", r.btoaPolyfill)
|
||||
|
||||
// TextEncoder/TextDecoder constructors
|
||||
r.registerTextEncoderDecoder(vm)
|
||||
|
||||
// URL class for URL parsing
|
||||
r.registerURLClass(vm)
|
||||
|
||||
// JSON global (browser-compatible)
|
||||
r.registerJSONGlobal(vm)
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
// authOpenUrl requests Flutter to open an OAuth URL
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "auth URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
authURL := call.Arguments[0].String()
|
||||
callbackURL := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
// Store pending auth request for Flutter to pick up
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
// Update auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PendingAuthURL = authURL
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Auth URL will be opened by the app",
|
||||
})
|
||||
}
|
||||
|
||||
// authGetCode gets the auth code (set by Flutter after OAuth callback)
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.AuthCode == "" {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Can accept either just auth code or an object with tokens
|
||||
arg := call.Arguments[0].Export()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
state.AuthCode = v
|
||||
case map[string]interface{}:
|
||||
if code, ok := v["code"].(string); ok {
|
||||
state.AuthCode = code
|
||||
}
|
||||
if accessToken, ok := v["access_token"].(string); ok {
|
||||
state.AccessToken = accessToken
|
||||
state.IsAuthenticated = true
|
||||
}
|
||||
if refreshToken, ok := v["refresh_token"].(string); ok {
|
||||
state.RefreshToken = refreshToken
|
||||
}
|
||||
if expiresIn, ok := v["expires_in"].(float64); ok {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authClear clears all auth state for the extension
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
delete(pendingAuthRequests, r.extensionID)
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authIsAuthenticated checks if extension has valid auth
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
// authGetTokens returns current tokens (for extension to use in API calls)
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"access_token": state.AccessToken,
|
||||
"refresh_token": state.RefreshToken,
|
||||
"is_authenticated": state.IsAuthenticated,
|
||||
}
|
||||
|
||||
if !state.ExpiresAt.IsZero() {
|
||||
result["expires_at"] = state.ExpiresAt.Unix()
|
||||
result["is_expired"] = time.Now().After(state.ExpiresAt)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// ==================== PKCE Support ====================
|
||||
|
||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
length = 43
|
||||
}
|
||||
if length > 128 {
|
||||
length = 128
|
||||
}
|
||||
|
||||
// Generate random bytes
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use base64url encoding without padding (RFC 7636 compliant)
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Trim to exact length
|
||||
if len(verifier) > length {
|
||||
verifier = verifier[:length]
|
||||
}
|
||||
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// generatePKCEChallenge generates a code challenge from verifier using S256 method
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// authGeneratePKCE generates a PKCE code verifier and challenge pair
|
||||
// Returns: { verifier: string, challenge: string, method: "S256" }
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
// Default length is 64 characters
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := generatePKCEVerifier(length)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store in auth state for later use
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.PKCEVerifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": state.PKCEVerifier,
|
||||
"challenge": state.PKCEChallenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
|
||||
if authURL == "" || clientID == "" || redirectURI == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
// Generate PKCE
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
|
||||
})
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store PKCE in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
// Build OAuth URL with PKCE parameters
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("invalid authUrl: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
query.Set("client_id", clientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("response_type", "code")
|
||||
query.Set("code_challenge", challenge)
|
||||
query.Set("code_challenge_method", "S256")
|
||||
|
||||
if scope != "" {
|
||||
query.Set("scope", scope)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
for k, v := range extraParams {
|
||||
query.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
fullAuthURL := parsedURL.String()
|
||||
|
||||
// Store pending auth request for Flutter
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: fullAuthURL,
|
||||
CallbackURL: redirectURI,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"authUrl": fullAuthURL,
|
||||
"pkce": map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
// Uses the stored PKCE verifier automatically
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
tokenURL, _ := config["tokenUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
code, _ := config["code"].(string)
|
||||
|
||||
if tokenURL == "" || clientID == "" || code == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "tokenUrl, clientId, and code are required",
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier
|
||||
extensionAuthStateMu.RLock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
var verifier string
|
||||
if exists {
|
||||
verifier = state.PKCEVerifier
|
||||
}
|
||||
extensionAuthStateMu.RUnlock()
|
||||
|
||||
if verifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Build token request body
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("code", code)
|
||||
formData.Set("code_verifier", verifier)
|
||||
if redirectURI != "" {
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// Make token request
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": string(body),
|
||||
})
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": errMsg,
|
||||
"error_description": errDesc,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract tokens
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
|
||||
if accessToken == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": string(body),
|
||||
})
|
||||
}
|
||||
|
||||
// Store tokens in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists = extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.AccessToken = accessToken
|
||||
state.RefreshToken = refreshToken
|
||||
state.IsAuthenticated = true
|
||||
if expiresIn > 0 {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
// Clear PKCE after successful exchange
|
||||
state.PKCEVerifier = ""
|
||||
state.PKCEChallenge = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
// Return full token response
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": tokenResp["token_type"],
|
||||
}
|
||||
if expiresIn > 0 {
|
||||
result["expires_in"] = expiresIn
|
||||
}
|
||||
// Include any additional fields from response
|
||||
if scope, ok := tokenResp["scope"].(string); ok {
|
||||
result["scope"] = scope
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Package gobackend provides FFmpeg API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== FFmpeg API (Post-Processing) ====================
|
||||
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
||||
type FFmpegCommand struct {
|
||||
ExtensionID string
|
||||
Command string
|
||||
InputPath string
|
||||
OutputPath string
|
||||
Completed bool
|
||||
Success bool
|
||||
Error string
|
||||
Output string
|
||||
}
|
||||
|
||||
// Global FFmpeg command queue
|
||||
var (
|
||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||
ffmpegCommandsMu sync.RWMutex
|
||||
ffmpegCommandID int64
|
||||
)
|
||||
|
||||
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
|
||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
return ffmpegCommands[commandID]
|
||||
}
|
||||
|
||||
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
|
||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
if cmd, exists := ffmpegCommands[commandID]; exists {
|
||||
cmd.Completed = true
|
||||
cmd.Success = success
|
||||
cmd.Output = output
|
||||
cmd.Error = errorMsg
|
||||
}
|
||||
}
|
||||
|
||||
// ClearFFmpegCommand removes a completed FFmpeg command
|
||||
func ClearFFmpegCommand(commandID string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
// ffmpegExecute queues an FFmpeg command for execution by Flutter
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "command is required",
|
||||
})
|
||||
}
|
||||
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
// Generate unique command ID
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||
ffmpegCommands[cmdID] = &FFmpegCommand{
|
||||
ExtensionID: r.extensionID,
|
||||
Command: command,
|
||||
Completed: false,
|
||||
}
|
||||
ffmpegCommandsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||
|
||||
// Wait for completion (with timeout)
|
||||
timeout := 5 * time.Minute
|
||||
start := time.Now()
|
||||
for {
|
||||
ffmpegCommandsMu.RLock()
|
||||
cmd := ffmpegCommands[cmdID]
|
||||
completed := cmd != nil && cmd.Completed
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
if completed {
|
||||
ffmpegCommandsMu.RLock()
|
||||
result := map[string]interface{}{
|
||||
"success": cmd.Success,
|
||||
"output": cmd.Output,
|
||||
}
|
||||
if cmd.Error != "" {
|
||||
result["error"] = cmd.Error
|
||||
}
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
// Cleanup
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
if time.Since(start) > timeout {
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "FFmpeg command timed out",
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpegGetInfo gets audio file information using FFprobe
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
// Use Go's built-in audio quality function
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"bit_depth": quality.BitDepth,
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
})
|
||||
}
|
||||
|
||||
// ffmpegConvert is a helper for common conversion operations
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "input and output paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Get options if provided
|
||||
options := map[string]interface{}{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||
options = opts
|
||||
}
|
||||
}
|
||||
|
||||
// Build FFmpeg command
|
||||
var cmdParts []string
|
||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||
|
||||
// Audio codec
|
||||
if codec, ok := options["codec"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-c:a", codec)
|
||||
}
|
||||
|
||||
// Bitrate
|
||||
if bitrate, ok := options["bitrate"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||
}
|
||||
|
||||
// Sample rate
|
||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||
}
|
||||
|
||||
// Channels
|
||||
if channels, ok := options["channels"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||
}
|
||||
|
||||
// Overwrite output
|
||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
// Execute via ffmpegExecute
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
return r.ffmpegExecute(execCall)
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
// Package gobackend provides File API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
// List of allowed directories for file operations (set by Go backend for download operations)
|
||||
var (
|
||||
allowedDownloadDirs []string
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
|
||||
// This should be called by the Go backend when setting up download paths
|
||||
func SetAllowedDownloadDirs(dirs []string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
allowedDownloadDirs = dirs
|
||||
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||
}
|
||||
|
||||
// AddAllowedDownloadDir adds a directory to the allowed list
|
||||
func AddAllowedDownloadDir(dir string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err == nil {
|
||||
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
|
||||
}
|
||||
}
|
||||
|
||||
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
|
||||
func isPathInAllowedDirs(absPath string) bool {
|
||||
allowedDownloadDirsMu.RLock()
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
|
||||
for _, allowedDir := range allowedDownloadDirs {
|
||||
if strings.HasPrefix(absPath, allowedDir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validatePath checks if the path is within the extension's sandbox
|
||||
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
|
||||
// Extensions should use relative paths for their own data storage
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
// Check if extension has file permission
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
|
||||
// Clean and resolve the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// SECURITY: Block absolute paths by default
|
||||
// Only allow if path is in explicitly allowed download directories
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Check if path is in allowed download directories
|
||||
if isPathInAllowedDirs(absPath) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// Block all other absolute paths
|
||||
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||
}
|
||||
|
||||
// For relative paths, join with data directory (extension's sandbox)
|
||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Ensure path is within data directory (prevent path traversal)
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !strings.HasPrefix(absPath, absDataDir) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// fileDownload downloads a file from URL to the specified path
|
||||
// Supports progress callback via options.onProgress
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "URL and output path are required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate output path (allows absolute paths for download queue)
|
||||
fullPath, err := r.validatePath(outputPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get options if provided
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Extract headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
headers = make(map[string]string)
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
// Extract onProgress callback
|
||||
if progressVal, ok := opts["onProgress"]; ok {
|
||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
// Download file
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
})
|
||||
}
|
||||
|
||||
// Create output file
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get content length for progress
|
||||
contentLength := resp.ContentLength
|
||||
|
||||
// Copy content with progress reporting
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024) // 32KB buffer
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := out.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
ew = fmt.Errorf("invalid write result")
|
||||
}
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
})
|
||||
}
|
||||
if nr != nw {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "short write",
|
||||
})
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if onProgress != nil && contentLength > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read response: %v", er),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"size": written,
|
||||
})
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists in the sandbox
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, err = os.Stat(fullPath)
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
// fileDelete deletes a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// fileRead reads a file from the sandbox
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(data),
|
||||
})
|
||||
}
|
||||
|
||||
// fileWrite writes data to a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
data := call.Arguments[1].String()
|
||||
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
})
|
||||
}
|
||||
|
||||
// fileCopy copies a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Read source file
|
||||
data, err := os.ReadFile(fullSrc)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Write to destination
|
||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write destination: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
// fileMove moves/renames a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Rename(fullSrc, fullDst); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to move file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
// fileGetSize returns the size of a file in bytes
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"size": info.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// Package gobackend provides HTTP API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
|
||||
// HTTPResponse represents the response from an HTTP request
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
// validateDomain checks if the domain is allowed by the extension's permissions
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
|
||||
// Block private/local network access (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||
}
|
||||
|
||||
if !r.manifest.IsDomainAllowed(domain) {
|
||||
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpGet performs a GET request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set default User-Agent if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPost performs a POST request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get body if provided - support both string and object
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
// Fallback to string conversion
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
// Usage: http.request(url, options) where options = { method, body, headers }
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Default options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// Parse options if provided
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Get method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Get body - support both string and object
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
// Return response with helper properties
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
||||
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
||||
if method == "DELETE" {
|
||||
// http.delete(url, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// http.put(url, body, headers) / http.patch(url, body, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpClearCookies clears all cookies for this extension
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
jar.mu.Unlock()
|
||||
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package gobackend provides Track Matching API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
// matchingCompareStrings compares two strings with fuzzy matching
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
|
||||
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
|
||||
|
||||
if str1 == str2 {
|
||||
return r.vm.ToValue(1.0)
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance-based similarity
|
||||
similarity := calculateStringSimilarity(str1, str2)
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
// matchingCompareDuration compares two durations with tolerance
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
dur1 := int(call.Arguments[0].ToInteger())
|
||||
dur2 := int(call.Arguments[1].ToInteger())
|
||||
|
||||
// Default tolerance: 3 seconds
|
||||
tolerance := 3000 // milliseconds
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||
tolerance = int(call.Arguments[2].ToInteger())
|
||||
}
|
||||
|
||||
diff := dur1 - dur2
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
// matchingNormalizeString normalizes a string for comparison
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
str := call.Arguments[0].String()
|
||||
normalized := normalizeStringForMatching(str)
|
||||
return r.vm.ToValue(normalized)
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if len(s1) == 0 && len(s2) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Use Levenshtein distance
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := len(s1)
|
||||
if len(s2) > maxLen {
|
||||
maxLen = len(s2)
|
||||
}
|
||||
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := range matrix[0] {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 1
|
||||
if s1[i-1] == s2[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// normalizeStringForMatching normalizes a string for comparison
|
||||
func normalizeStringForMatching(s string) string {
|
||||
// Convert to lowercase
|
||||
s = strings.ToLower(s)
|
||||
|
||||
// Remove common suffixes/prefixes
|
||||
suffixes := []string{
|
||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||
" (explicit)", " (clean)", " [explicit]", " [clean]",
|
||||
" (album version)", " (single version)", " (radio edit)",
|
||||
" (feat.", " (ft.", " feat.", " ft.",
|
||||
}
|
||||
for _, suffix := range suffixes {
|
||||
if idx := strings.Index(s, suffix); idx != -1 {
|
||||
s = s[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove special characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse multiple spaces
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These polyfills make porting browser/Node.js libraries easier
|
||||
// without compromising sandbox security
|
||||
|
||||
// fetchPolyfill implements browser-compatible fetch() API
|
||||
// Returns a Promise-like object with json(), text() methods
|
||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Parse options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Body - support string, object (auto-stringify), or nil
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range hv {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Set defaults if not provided
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Create Response object (browser-compatible)
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
|
||||
// Store body for methods
|
||||
bodyString := string(body)
|
||||
|
||||
// text() method - returns body as string
|
||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(bodyString)
|
||||
})
|
||||
|
||||
// json() method - parses body as JSON
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
return r.vm.ToValue(result)
|
||||
})
|
||||
|
||||
// arrayBuffer() method - returns body as array (simplified)
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
// Return as array of bytes
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(byteArray)
|
||||
})
|
||||
|
||||
return responseObj
|
||||
}
|
||||
|
||||
// createFetchError creates a fetch error response
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
errorObj.Set("status", 0)
|
||||
errorObj.Set("statusText", "Network Error")
|
||||
errorObj.Set("error", message)
|
||||
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue("")
|
||||
})
|
||||
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
return goja.Undefined()
|
||||
})
|
||||
return errorObj
|
||||
}
|
||||
|
||||
// atobPolyfill implements browser atob() - decode base64 to string
|
||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
// Try URL-safe base64
|
||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// btoaPolyfill implements browser btoa() - encode string to base64
|
||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
// TextEncoder constructor
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
|
||||
// encode() method - string to Uint8Array
|
||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]byte{})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
// Return as array (Uint8Array-like)
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
// encodeInto() method
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"read": len(input),
|
||||
"written": len([]byte(input)),
|
||||
})
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// TextDecoder constructor
|
||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
decoder := call.This
|
||||
|
||||
// Get encoding from arguments (default: utf-8)
|
||||
encoding := "utf-8"
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
encoding = call.Arguments[0].String()
|
||||
}
|
||||
decoder.Set("encoding", encoding)
|
||||
decoder.Set("fatal", false)
|
||||
decoder.Set("ignoreBOM", false)
|
||||
|
||||
// decode() method - Uint8Array to string
|
||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
// Handle different input types
|
||||
input := call.Arguments[0].Export()
|
||||
var bytes []byte
|
||||
|
||||
switch v := input.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case []interface{}:
|
||||
bytes = make([]byte, len(v))
|
||||
for i, val := range v {
|
||||
switch n := val.(type) {
|
||||
case int64:
|
||||
bytes[i] = byte(n)
|
||||
case float64:
|
||||
bytes[i] = byte(n)
|
||||
case int:
|
||||
bytes[i] = byte(n)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
// Already a string, just return it
|
||||
return vm.ToValue(v)
|
||||
default:
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(string(bytes))
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// registerURLClass registers the URL class for URL parsing
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
|
||||
if len(call.Arguments) < 1 {
|
||||
urlObj.Set("href", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Handle relative URLs with base
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
baseStr := call.Arguments[1].String()
|
||||
baseURL, err := url.Parse(baseStr)
|
||||
if err == nil {
|
||||
relURL, err := url.Parse(urlStr)
|
||||
if err == nil {
|
||||
urlStr = baseURL.ResolveReference(relURL).String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
urlObj.Set("href", urlStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set URL properties
|
||||
urlObj.Set("href", parsed.String())
|
||||
urlObj.Set("protocol", parsed.Scheme+":")
|
||||
urlObj.Set("host", parsed.Host)
|
||||
urlObj.Set("hostname", parsed.Hostname())
|
||||
urlObj.Set("port", parsed.Port())
|
||||
urlObj.Set("pathname", parsed.Path)
|
||||
urlObj.Set("search", "")
|
||||
if parsed.RawQuery != "" {
|
||||
urlObj.Set("search", "?"+parsed.RawQuery)
|
||||
}
|
||||
urlObj.Set("hash", "")
|
||||
if parsed.Fragment != "" {
|
||||
urlObj.Set("hash", "#"+parsed.Fragment)
|
||||
}
|
||||
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
|
||||
urlObj.Set("username", parsed.User.Username())
|
||||
password, _ := parsed.User.Password()
|
||||
urlObj.Set("password", password)
|
||||
|
||||
// searchParams object
|
||||
searchParams := vm.NewObject()
|
||||
queryValues := parsed.Query()
|
||||
|
||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
if val := queryValues.Get(key); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues[key])
|
||||
})
|
||||
|
||||
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues.Has(key))
|
||||
})
|
||||
|
||||
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(queryValues.Encode())
|
||||
})
|
||||
|
||||
urlObj.Set("searchParams", searchParams)
|
||||
|
||||
// toString method
|
||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
// toJSON method
|
||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// URLSearchParams constructor
|
||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||
paramsObj := call.This
|
||||
values := url.Values{}
|
||||
|
||||
// Parse initial value if provided
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
init := call.Arguments[0].Export()
|
||||
switch v := init.(type) {
|
||||
case string:
|
||||
// Parse query string
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]interface{}:
|
||||
for k, val := range v {
|
||||
values.Set(k, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 1 {
|
||||
values.Del(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
if val := values.Get(call.Arguments[0].String()); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
return vm.ToValue(values[call.Arguments[0].String()])
|
||||
})
|
||||
|
||||
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(values.Has(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(values.Encode())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// registerJSONGlobal ensures JSON global is properly set up
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
// JSON is already built-in to Goja, but we can enhance it
|
||||
// This ensures JSON.parse and JSON.stringify work as expected
|
||||
|
||||
// The built-in JSON object should already work, but let's verify
|
||||
// and add any missing functionality if needed
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
parse: function(text) {
|
||||
return utils.parseJSON(text);
|
||||
},
|
||||
stringify: function(value, replacer, space) {
|
||||
return utils.stringifyJSON(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
`
|
||||
_, _ = vm.RunString(jsonScript)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Storage API ====================
|
||||
|
||||
// getStoragePath returns the path to the extension's storage file
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
// loadStorage loads the storage data from disk
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var storage map[string]interface{}
|
||||
if err := json.Unmarshal(data, &storage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// saveStorage saves the storage data to disk
|
||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := json.MarshalIndent(storage, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0644)
|
||||
}
|
||||
|
||||
// storageGet retrieves a value from storage
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := storage[key]
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// storageSet stores a value in storage
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
storage[key] = value
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// storageRemove removes a value from storage
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(storage, key)
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// ==================== Credentials API (Encrypted Storage) ====================
|
||||
|
||||
// getCredentialsPath returns the path to the extension's encrypted credentials file
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
// getSaltPath returns the path to the extension's encryption salt file
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
// getOrCreateSalt gets existing salt or creates a new random one
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
// Try to read existing salt
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
if err == nil && len(salt) == 32 {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
// Generate new random salt (32 bytes)
|
||||
salt = make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
// Save salt to file
|
||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||
}
|
||||
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
// getEncryptionKey derives an encryption key from extension ID + random salt
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
// Get or create per-installation random salt
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Combine extension ID + random salt for key derivation
|
||||
// This makes each installation unique, preventing mass decryption attacks
|
||||
combined := append([]byte(r.extensionID), salt...)
|
||||
hash := sha256.Sum256(combined)
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
// loadCredentials loads and decrypts credentials from disk
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
credPath := r.getCredentialsPath()
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
decrypted, err := decryptAES(data, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// saveCredentials encrypts and saves credentials to disk
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
encrypted, err := encryptAES(data, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
|
||||
}
|
||||
|
||||
// credentialsStore stores an encrypted credential
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "key and value are required",
|
||||
})
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
creds[key] = value
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// credentialsGet retrieves a decrypted credential
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := creds[key]
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// credentialsRemove removes a credential
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(creds, key)
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// credentialsHas checks if a credential exists
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, exists := creds[key]
|
||||
return r.vm.ToValue(exists)
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities ====================
|
||||
|
||||
// encryptAES encrypts data using AES-GCM
|
||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// decryptAES decrypts data using AES-GCM
|
||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Package gobackend provides Utility functions for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Utility Functions ====================
|
||||
|
||||
// base64Encode encodes a string to base64
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
// base64Decode decodes a base64 string
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// md5Hash computes MD5 hash of a string
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := md5.Sum([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// sha256Hash computes SHA256 hash of a string
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// hmacSHA256 computes HMAC-SHA256 of a message with a key
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
|
||||
// Arguments: message (string or array of bytes), key (string or array of bytes)
|
||||
// Returns: array of bytes (for TOTP dynamic truncation)
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get key - can be string or array of bytes
|
||||
var keyBytes []byte
|
||||
keyArg := call.Arguments[0].Export()
|
||||
switch k := keyArg.(type) {
|
||||
case string:
|
||||
keyBytes = []byte(k)
|
||||
case []interface{}:
|
||||
keyBytes = make([]byte, len(k))
|
||||
for i, v := range k {
|
||||
if num, ok := v.(int64); ok {
|
||||
keyBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
keyBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get message - can be string or array of bytes
|
||||
var msgBytes []byte
|
||||
msgArg := call.Arguments[1].Export()
|
||||
switch m := msgArg.(type) {
|
||||
case string:
|
||||
msgBytes = []byte(m)
|
||||
case []interface{}:
|
||||
msgBytes = make([]byte, len(m))
|
||||
for i, v := range m {
|
||||
if num, ok := v.(int64); ok {
|
||||
msgBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
msgBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, keyBytes)
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
// Convert to array of numbers for JavaScript
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
// parseJSON parses a JSON string
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
|
||||
var result interface{}
|
||||
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// stringifyJSON converts a value to JSON string
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].Export()
|
||||
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities for Extensions ====================
|
||||
|
||||
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "plaintext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
plaintext := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "ciphertext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
ciphertextB64 := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(decrypted),
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoGenerateKey generates a random encryption key
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32 // Default 256-bit key
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
key := make([]byte, length)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"key": base64.StdEncoding.EncodeToString(key),
|
||||
"hex": hex.EncodeToString(key),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Logging Functions ====================
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
parts := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ==================== Go Backend Wrappers ====================
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
// RegisterGoBackendAPIs adds more Go backend functions to the VM
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
gobackendObj = vm.NewObject()
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
}
|
||||
|
||||
obj := gobackendObj.(*goja.Object)
|
||||
|
||||
// Expose sanitizeFilename
|
||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
// Expose getAudioQuality
|
||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"bitDepth": quality.BitDepth,
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
})
|
||||
})
|
||||
|
||||
// Expose buildFilename
|
||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
template := call.Arguments[0].String()
|
||||
metadataObj := call.Arguments[1].Export()
|
||||
|
||||
metadata, ok := metadataObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package gobackend provides extension settings storage
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ExtensionSettingsStore manages settings for all extensions
|
||||
type ExtensionSettingsStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
settings map[string]map[string]interface{} // extensionID -> settings
|
||||
}
|
||||
|
||||
// Global settings store
|
||||
var (
|
||||
globalSettingsStore *ExtensionSettingsStore
|
||||
globalSettingsStoreOnce sync.Once
|
||||
)
|
||||
|
||||
// GetExtensionSettingsStore returns the global settings store
|
||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
globalSettingsStoreOnce.Do(func() {
|
||||
globalSettingsStore = &ExtensionSettingsStore{
|
||||
settings: make(map[string]map[string]interface{}),
|
||||
}
|
||||
})
|
||||
return globalSettingsStore
|
||||
}
|
||||
|
||||
// SetDataDir sets the data directory for settings storage
|
||||
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.dataDir = dataDir
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
// Load all existing settings
|
||||
return s.loadAllSettings()
|
||||
}
|
||||
|
||||
// getSettingsPath returns the path to an extension's settings file
|
||||
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||
}
|
||||
|
||||
// loadAllSettings loads settings for all extensions from disk
|
||||
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
entries, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
extensionID := entry.Name()
|
||||
settings, err := s.loadSettings(extensionID)
|
||||
if err != nil {
|
||||
GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err)
|
||||
continue
|
||||
}
|
||||
s.settings[extensionID] = settings
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSettings loads settings for a specific extension
|
||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// saveSettings saves settings for a specific extension
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(settingsPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(settingsPath, data, 0644)
|
||||
}
|
||||
|
||||
// Get retrieves a setting value for an extension
|
||||
// Returns error if extension or key not found (gomobile compatible)
|
||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("extension '%s' settings not found", extensionID)
|
||||
}
|
||||
|
||||
value, exists := extSettings[key]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all settings for an extension
|
||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range extSettings {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Set stores a setting value for an extension
|
||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.settings[extensionID]; !exists {
|
||||
s.settings[extensionID] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
s.settings[extensionID][key] = value
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
// SetAll stores all settings for an extension
|
||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.settings[extensionID] = settings
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, settings)
|
||||
}
|
||||
|
||||
// Remove removes a setting for an extension
|
||||
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(extSettings, key)
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, extSettings)
|
||||
}
|
||||
|
||||
// RemoveAll removes all settings for an extension
|
||||
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.settings, extensionID)
|
||||
|
||||
// Remove settings file
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllExtensionSettings returns settings for all extensions as JSON
|
||||
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := json.Marshal(s.settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extension categories
|
||||
const (
|
||||
CategoryMetadata = "metadata"
|
||||
CategoryDownload = "download"
|
||||
CategoryUtility = "utility"
|
||||
CategoryLyrics = "lyrics"
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
// StoreExtension represents an extension in the store
|
||||
type StoreExtension struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
// Alternative camelCase fields (for flexibility)
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
}
|
||||
|
||||
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
}
|
||||
if e.DisplayNameAlt != "" {
|
||||
return e.DisplayNameAlt
|
||||
}
|
||||
return e.Name
|
||||
}
|
||||
|
||||
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
}
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
}
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
}
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
// StoreRegistry represents the extension registry
|
||||
type StoreRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Extensions []StoreExtension `json:"extensions"`
|
||||
}
|
||||
|
||||
// StoreExtensionResponse is the normalized response sent to Flutter
|
||||
type StoreExtensionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
InstalledVersion string `json:"installed_version,omitempty"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
// ToResponse converts StoreExtension to normalized response
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
Version: e.Version,
|
||||
Author: e.Author,
|
||||
Description: e.Description,
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
Category: e.Category,
|
||||
Tags: e.Tags,
|
||||
Downloads: e.Downloads,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
MinAppVersion: e.getMinAppVersion(),
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionStore manages the extension store
|
||||
type ExtensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
cache *StoreRegistry
|
||||
cacheMu sync.RWMutex
|
||||
cacheTime time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
extensionStore *ExtensionStore
|
||||
extensionStoreMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
// InitExtensionStore initializes the extension store
|
||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
|
||||
if extensionStore == nil {
|
||||
extensionStore = &ExtensionStore{
|
||||
registryURL: defaultRegistryURL,
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
// Try to load from disk cache
|
||||
extensionStore.loadDiskCache()
|
||||
}
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// GetExtensionStore returns the singleton store instance
|
||||
func GetExtensionStore() *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// loadDiskCache loads cached registry from disk
|
||||
func (s *ExtensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var cacheData struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.cache = &cacheData.Registry
|
||||
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
// saveDiskCache saves registry to disk cache
|
||||
func (s *ExtensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cacheData := struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}{
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cacheData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
// FetchRegistry fetches the extension registry from GitHub
|
||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Return cached if valid and not forcing refresh
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
// Return cached data if available on network error
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var registry StoreRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
|
||||
s.cache = ®istry
|
||||
s.cacheTime = time.Now()
|
||||
s.saveDiskCache()
|
||||
|
||||
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
// GetExtensionsWithStatus returns extensions with installation status
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
installed := make(map[string]string) // id -> version
|
||||
|
||||
if manager != nil {
|
||||
for _, ext := range manager.GetAllExtensions() {
|
||||
installed[ext.ID] = ext.Manifest.Version
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||
for i, ext := range registry.Extensions {
|
||||
resp := ext.ToResponse()
|
||||
|
||||
if installedVersion, ok := installed[ext.ID]; ok {
|
||||
resp.IsInstalled = true
|
||||
resp.InstalledVersion = installedVersion
|
||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||
}
|
||||
|
||||
result[i] = resp
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadExtension downloads an extension package to the specified path
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ext *StoreExtension
|
||||
for _, e := range registry.Extensions {
|
||||
if e.ID == extensionID {
|
||||
ext = &e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ext == nil {
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCategories returns all available categories
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
CategoryDownload,
|
||||
CategoryUtility,
|
||||
CategoryLyrics,
|
||||
CategoryIntegration,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchExtensions searches extensions by query
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query == "" && category == "" {
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
var result []StoreExtensionResponse
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
// Filter by category
|
||||
if category != "" && ext.Category != category {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by query
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
// Check tags
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, ext)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ClearCache clears the in-memory and disk cache
|
||||
func (s *ExtensionStore) ClearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Cache cleared")
|
||||
}
|
||||
|
||||
// Helper: case-insensitive contains
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return containsStr(toLower(s), substr)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
result[i] = c
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestParseManifest_Valid(t *testing.T) {
|
||||
validManifest := `{
|
||||
"name": "test-provider",
|
||||
"displayName": "Test Provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"],
|
||||
"permissions": {
|
||||
"network": ["api.test.com"],
|
||||
"storage": true
|
||||
}
|
||||
}`
|
||||
|
||||
manifest, err := ParseManifest([]byte(validManifest))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
|
||||
}
|
||||
|
||||
if manifest.Name != "test-provider" {
|
||||
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
|
||||
}
|
||||
|
||||
if manifest.Version != "1.0.0" {
|
||||
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
|
||||
}
|
||||
|
||||
if !manifest.IsMetadataProvider() {
|
||||
t.Error("Expected IsMetadataProvider() to return true")
|
||||
}
|
||||
|
||||
if manifest.IsDownloadProvider() {
|
||||
t.Error("Expected IsDownloadProvider() to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingName(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"]
|
||||
}`
|
||||
|
||||
_, err := ParseManifest([]byte(invalidManifest))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingType(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"name": "test-provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension"
|
||||
}`
|
||||
|
||||
_, err := ParseManifest([]byte(invalidManifest))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDomainAllowed(t *testing.T) {
|
||||
manifest := &ExtensionManifest{
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.test.com", "*.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
domain string
|
||||
expected bool
|
||||
}{
|
||||
{"api.test.com", true},
|
||||
{"api.example.com", true},
|
||||
{"sub.example.com", true},
|
||||
{"notallowed.com", false},
|
||||
{"test.com", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := manifest.IsDomainAllowed(tt.domain)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.allowed.com", "*.wildcard.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test allowed domains
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
|
||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||
}
|
||||
|
||||
// Test blocked domains
|
||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||
t.Error("Expected blocked.com to be denied")
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||
t.Error("Expected notallowed.com to be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true, // Enable file permission for test
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test valid path within sandbox
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||
}
|
||||
if validPath == "" {
|
||||
t.Error("Expected non-empty path")
|
||||
}
|
||||
|
||||
// Test path traversal attack
|
||||
_, err = runtime.validatePath("../../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Expected path traversal to be blocked")
|
||||
}
|
||||
|
||||
// Test nested path within sandbox (should be allowed)
|
||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||
}
|
||||
if nestedPath == "" {
|
||||
t.Error("Expected non-empty nested path")
|
||||
}
|
||||
|
||||
// Test absolute path should be blocked (security fix)
|
||||
// Use platform-appropriate absolute path
|
||||
var absPath string
|
||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||
absPath = "C:\\Windows\\System32\\test.txt" // Windows
|
||||
} else {
|
||||
absPath = "/etc/passwd" // Unix
|
||||
}
|
||||
_, err = runtime.validatePath(absPath)
|
||||
if err == nil {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
// Test that extension without file permission is blocked
|
||||
extNoFile := &LoadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: false, // No file permission
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||
_, err = runtimeNoFile.validatePath("test.txt")
|
||||
if err == nil {
|
||||
t.Error("Expected file access to be denied without file permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
// Test base64 encode/decode
|
||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Encode failed: %v", err)
|
||||
}
|
||||
if result.String() != "aGVsbG8=" {
|
||||
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Decode failed: %v", err)
|
||||
}
|
||||
if result.String() != "hello" {
|
||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test MD5
|
||||
result, err = vm.RunString(`utils.md5("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("md5 failed: %v", err)
|
||||
}
|
||||
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
|
||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test JSON parse/stringify
|
||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
}
|
||||
// JSON output may vary in order, just check it's valid
|
||||
if result.String() == "" {
|
||||
t.Error("Expected non-empty JSON string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test that private IPs are blocked (SSRF protection)
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
"http://127.0.0.1/admin",
|
||||
"http://192.168.1.1/admin",
|
||||
"http://10.0.0.1/admin",
|
||||
"http://172.16.0.1/admin",
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
||||
"http://router.local/admin",
|
||||
}
|
||||
|
||||
for _, url := range privateIPs {
|
||||
err := runtime.validateDomain(url)
|
||||
if err == nil {
|
||||
t.Errorf("Expected private IP/host '%s' to be blocked", url)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that allowed public domain still works
|
||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
expected bool
|
||||
}{
|
||||
// Private IPs should be blocked
|
||||
{"localhost", true},
|
||||
{"127.0.0.1", true},
|
||||
{"127.0.0.2", true},
|
||||
{"10.0.0.1", true},
|
||||
{"10.255.255.255", true},
|
||||
{"172.16.0.1", true},
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.169.254", true}, // AWS metadata
|
||||
{"router.local", true},
|
||||
{"mydevice.local", true},
|
||||
|
||||
// Public IPs should be allowed
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"api.example.com", false},
|
||||
{"google.com", false},
|
||||
{"172.15.0.1", false}, // Just outside 172.16-31 range
|
||||
{"172.32.0.1", false}, // Just outside 172.16-31 range
|
||||
{"192.167.0.1", false}, // Not 192.168.x.x
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := isPrivateIP(tt.host)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package gobackend provides timeout execution for extension JS code
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// JSExecutionError represents an error during JS execution
|
||||
type JSExecutionError struct {
|
||||
Message string
|
||||
IsTimeout bool
|
||||
}
|
||||
|
||||
func (e *JSExecutionError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RunWithTimeout executes JavaScript code with a timeout
|
||||
// Returns the result value and any error (including timeout)
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Channel to receive result
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
// Track if we've interrupted
|
||||
var interrupted bool
|
||||
var interruptMu sync.Mutex
|
||||
|
||||
// Run script in goroutine
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Check if this was our interrupt
|
||||
interruptMu.Lock()
|
||||
wasInterrupted := interrupted
|
||||
interruptMu.Unlock()
|
||||
|
||||
if wasInterrupted {
|
||||
resultCh <- result{nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} else {
|
||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
val, err := vm.RunString(script)
|
||||
resultCh <- result{val, err}
|
||||
}()
|
||||
|
||||
// Wait for result or timeout
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
// Timeout - interrupt the VM
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
|
||||
// Wait a bit for the goroutine to finish
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
// If we got a result after interrupt, it might be the timeout error
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
// Force return timeout error
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||
// This should be used when you want to continue using the VM after a timeout
|
||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeout(vm, script, timeout)
|
||||
|
||||
// Clear any interrupt state so VM can be reused
|
||||
vm.ClearInterrupt()
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// IsTimeoutError checks if an error is a timeout error
|
||||
func IsTimeoutError(err error) bool {
|
||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||
return jsErr.IsTimeout
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -5,14 +5,19 @@ go 1.24.0
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
+264
-48
@@ -1,12 +1,17 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,12 +22,12 @@ import (
|
||||
func getRandomUserAgent() string {
|
||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||
|
||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||
|
||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
||||
|
||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||
winMajor,
|
||||
@@ -34,46 +39,48 @@ func getRandomUserAgent() string {
|
||||
|
||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||
func getRandomMacUserAgent() string {
|
||||
macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||
macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||
webkitMajor := rand.Intn(7) + 530
|
||||
webkitMinor := rand.Intn(7) + 30
|
||||
chromeMajor := rand.Intn(25) + 80
|
||||
chromeBuild := rand.Intn(1500) + 3000
|
||||
chromePatch := rand.Intn(65) + 60
|
||||
safariMajor := rand.Intn(7) + 530
|
||||
safariMinor := rand.Intn(6) + 30
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
macMajor,
|
||||
macMinor,
|
||||
webkitMajor,
|
||||
webkitMinor,
|
||||
chromeMajor,
|
||||
chromeBuild,
|
||||
chromePatch,
|
||||
safariMajor,
|
||||
safariMinor,
|
||||
)
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func getRandomMacUserAgent() string {
|
||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||
// webkitMajor := rand.Intn(7) + 530
|
||||
// webkitMinor := rand.Intn(7) + 30
|
||||
// chromeMajor := rand.Intn(25) + 80
|
||||
// chromeBuild := rand.Intn(1500) + 3000
|
||||
// chromePatch := rand.Intn(65) + 60
|
||||
// safariMajor := rand.Intn(7) + 530
|
||||
// safariMinor := rand.Intn(6) + 30
|
||||
//
|
||||
// return fmt.Sprintf(
|
||||
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
// macMajor,
|
||||
// macMinor,
|
||||
// webkitMajor,
|
||||
// webkitMinor,
|
||||
// chromeMajor,
|
||||
// chromeBuild,
|
||||
// chromePatch,
|
||||
// safariMajor,
|
||||
// safariMinor,
|
||||
// )
|
||||
// }
|
||||
|
||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||
func getRandomDesktopUserAgent() string {
|
||||
if rand.Intn(2) == 0 {
|
||||
return getRandomUserAgent() // Windows
|
||||
}
|
||||
return getRandomMacUserAgent() // Mac
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func getRandomDesktopUserAgent() string {
|
||||
// if rand.Intn(2) == 0 {
|
||||
// return getRandomUserAgent() // Windows
|
||||
// }
|
||||
// return getRandomMacUserAgent() // Mac
|
||||
// }
|
||||
|
||||
// Default timeout values
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||
DefaultMaxRetries = 3 // Default retry count
|
||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||
DefaultMaxRetries = 3 // Default retry count
|
||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||
)
|
||||
|
||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||
@@ -91,9 +98,9 @@ var sharedTransport = &http.Transport{
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||
DisableCompression: true, // FLAC is already compressed
|
||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||
DisableCompression: true, // FLAC is already compressed
|
||||
}
|
||||
|
||||
// Shared HTTP client for general requests (reuses connections)
|
||||
@@ -134,9 +141,15 @@ func CloseIdleConnections() {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
@@ -159,9 +172,11 @@ func DefaultRetryConfig() RetryConfig {
|
||||
|
||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||
// 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) {
|
||||
var lastErr error
|
||||
delay := config.InitialDelay
|
||||
requestURL := req.URL.String()
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Clone request for retry (body needs to be re-readable)
|
||||
@@ -171,7 +186,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
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 {
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
attempt+1, config.MaxRetries+1, err, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
@@ -192,17 +216,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
}
|
||||
lastErr = fmt.Errorf("rate limited (429)")
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
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
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
@@ -219,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
// calculateNextDelay calculates the next delay with exponential backoff
|
||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
if nextDelay > config.MaxDelay {
|
||||
nextDelay = config.MaxDelay
|
||||
}
|
||||
return nextDelay
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||
@@ -296,3 +343,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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, 1000),
|
||||
maxSize: 1000,
|
||||
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)
|
||||
}
|
||||
+24
-23
@@ -250,29 +250,30 @@ func msToLRCTimestamp(ms int64) string {
|
||||
|
||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||
// Use convertToLRCWithMetadata for full LRC with headers
|
||||
func convertToLRC(lyrics *LyricsResponse) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
for _, line := range lyrics.Lines {
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
builder.WriteString(timestamp)
|
||||
builder.WriteString(line.Words)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
for _, line := range lyrics.Lines {
|
||||
builder.WriteString(line.Words)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
// return ""
|
||||
// }
|
||||
//
|
||||
// var builder strings.Builder
|
||||
//
|
||||
// if lyrics.SyncType == "LINE_SYNCED" {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
// builder.WriteString(timestamp)
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// } else {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return builder.String()
|
||||
// }
|
||||
|
||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||
// Includes [ti:], [ar:], [by:] headers
|
||||
|
||||
+89
-57
@@ -58,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
|
||||
if 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:]...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
@@ -162,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
|
||||
if 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:]...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
@@ -257,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
if trackNum != "" {
|
||||
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")
|
||||
if discNum != "" {
|
||||
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
|
||||
}
|
||||
@@ -291,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
}
|
||||
|
||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||
keyUpper := strings.ToUpper(key) + "="
|
||||
for _, comment := range cmt.Comments {
|
||||
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" {
|
||||
return comment[len(key)+1:]
|
||||
if len(comment) > len(key) {
|
||||
// Case-insensitive comparison for Vorbis comments
|
||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||
if commentUpper == keyUpper {
|
||||
return comment[len(key)+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -356,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Try LYRICS tag first
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
|
||||
|
||||
// Fallback to UNSYNCEDLYRICS
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
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
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Check if it's a FLAC file
|
||||
if string(marker) == "fLaC" {
|
||||
// Continue reading FLAC metadata
|
||||
@@ -422,12 +447,20 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
// Parse bits per sample (5 bits)
|
||||
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{
|
||||
BitDepth: bitsPerSample,
|
||||
SampleRate: sampleRate,
|
||||
BitDepth: bitsPerSample,
|
||||
SampleRate: sampleRate,
|
||||
TotalSamples: totalSamples,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
||||
// First 4 bytes are size, next 4 should be "ftyp"
|
||||
file.Seek(0, 0) // Reset to beginning
|
||||
@@ -435,17 +468,16 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
if _, err := file.Read(header8); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
|
||||
if string(header8[4:8]) == "ftyp" {
|
||||
// It's an M4A/MP4 file, use M4A quality reader
|
||||
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||
return GetM4AQuality(filePath)
|
||||
}
|
||||
|
||||
|
||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// M4A (MP4/AAC) Metadata Embedding
|
||||
// ========================================
|
||||
@@ -466,21 +498,21 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
}
|
||||
|
||||
// Find udta atom inside moov, or create one
|
||||
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||
|
||||
|
||||
// Build new metadata atoms
|
||||
metaAtom := buildMetaAtom(metadata, coverData)
|
||||
|
||||
|
||||
var newData []byte
|
||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||
// udta exists, find meta inside it or replace
|
||||
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||
|
||||
|
||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||
// Replace existing meta atom
|
||||
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
||||
newData = append(newData, data[:metaPos]...)
|
||||
newData = append(newData, metaAtom...)
|
||||
newData = append(newData, data[metaPos+metaSize:]...)
|
||||
@@ -495,7 +527,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
newUdta[3] = byte(newUdtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, newUdtaContent...)
|
||||
|
||||
|
||||
newData = append(newData, data[:udtaPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||
@@ -511,14 +543,14 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
newUdta[3] = byte(udtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, udtaContent...)
|
||||
|
||||
|
||||
// Insert udta at end of moov
|
||||
insertPos := moovPos + moovSize
|
||||
newData = append(newData, data[:insertPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[insertPos:]...)
|
||||
}
|
||||
|
||||
|
||||
// Update moov size
|
||||
newMoovSize := moovSize + len(newData) - len(data)
|
||||
newData[moovPos] = byte(newMoovSize >> 24)
|
||||
@@ -538,7 +570,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
// findAtom finds an atom by name starting from offset
|
||||
func findAtom(data []byte, name string, offset int) int {
|
||||
for i := offset; i < len(data)-8; {
|
||||
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
|
||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
||||
if size < 8 {
|
||||
break
|
||||
}
|
||||
@@ -555,52 +587,52 @@ func findAtom(data []byte, name string, offset int) int {
|
||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
// Build ilst content
|
||||
var ilst []byte
|
||||
|
||||
|
||||
// ©nam - Title
|
||||
if metadata.Title != "" {
|
||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||
}
|
||||
|
||||
|
||||
// ©ART - Artist
|
||||
if metadata.Artist != "" {
|
||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||
}
|
||||
|
||||
|
||||
// ©alb - Album
|
||||
if metadata.Album != "" {
|
||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||
}
|
||||
|
||||
|
||||
// aART - Album Artist
|
||||
if metadata.AlbumArtist != "" {
|
||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||
}
|
||||
|
||||
|
||||
// ©day - Year/Date
|
||||
if metadata.Date != "" {
|
||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||
}
|
||||
|
||||
|
||||
// trkn - Track Number
|
||||
if metadata.TrackNumber > 0 {
|
||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||
}
|
||||
|
||||
|
||||
// disk - Disc Number
|
||||
if metadata.DiscNumber > 0 {
|
||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||
}
|
||||
|
||||
|
||||
// ©lyr - Lyrics
|
||||
if metadata.Lyrics != "" {
|
||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||
}
|
||||
|
||||
|
||||
// covr - Cover Art
|
||||
if len(coverData) > 0 {
|
||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||
}
|
||||
|
||||
|
||||
// Build ilst atom
|
||||
ilstSize := 8 + len(ilst)
|
||||
ilstAtom := make([]byte, 4)
|
||||
@@ -610,7 +642,7 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
ilstAtom[3] = byte(ilstSize)
|
||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||
ilstAtom = append(ilstAtom, ilst...)
|
||||
|
||||
|
||||
// Build hdlr atom (required for meta)
|
||||
hdlr := []byte{
|
||||
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, // null terminator
|
||||
}
|
||||
|
||||
|
||||
// Build meta atom
|
||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||
metaContent = append(metaContent, ilstAtom...)
|
||||
|
||||
|
||||
metaSize := 8 + len(metaContent)
|
||||
metaAtom := make([]byte, 4)
|
||||
metaAtom[0] = byte(metaSize >> 24)
|
||||
@@ -636,14 +668,14 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
metaAtom[3] = byte(metaSize)
|
||||
metaAtom = append(metaAtom, []byte("meta")...)
|
||||
metaAtom = append(metaAtom, metaContent...)
|
||||
|
||||
|
||||
return metaAtom
|
||||
}
|
||||
|
||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||
func buildTextAtom(name, value string) []byte {
|
||||
valueBytes := []byte(value)
|
||||
|
||||
|
||||
// data atom
|
||||
dataSize := 16 + len(valueBytes)
|
||||
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, 0) // locale
|
||||
dataAtom = append(dataAtom, valueBytes...)
|
||||
|
||||
|
||||
// container atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -665,7 +697,7 @@ func buildTextAtom(name, value string) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte(name)...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
@@ -682,7 +714,7 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
byte(total >> 8), byte(total), // total tracks
|
||||
0, 0, // padding
|
||||
}
|
||||
|
||||
|
||||
// trkn atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -692,7 +724,7 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("trkn")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
@@ -708,7 +740,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
byte(disc >> 8), byte(disc), // disc number
|
||||
byte(total >> 8), byte(total), // total discs
|
||||
}
|
||||
|
||||
|
||||
// disk atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -718,7 +750,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("disk")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
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' {
|
||||
imageType = 14 // PNG
|
||||
}
|
||||
|
||||
|
||||
// data atom
|
||||
dataSize := 16 + len(coverData)
|
||||
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, 0) // locale
|
||||
dataAtom = append(dataAtom, coverData...)
|
||||
|
||||
|
||||
// covr atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
@@ -751,7 +783,7 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("covr")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||
}
|
||||
|
||||
func preWarmTidalCache(isrc, trackName, artistName string) {
|
||||
func preWarmTidalCache(isrc, _, _ string) {
|
||||
downloader := NewTidalDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
@@ -272,7 +272,7 @@ func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
// Parse JSON (simplified - in production use proper JSON parsing)
|
||||
// For now, this is called from exports.go with proper parsing
|
||||
|
||||
|
||||
go PreWarmTrackCache(requests) // Run in background
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type ItemProgress struct {
|
||||
ItemID string `json:"item_id"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
@@ -204,11 +204,12 @@ func setDownloadDir(path string) error {
|
||||
}
|
||||
|
||||
// getDownloadDir returns the default download directory
|
||||
func getDownloadDir() string {
|
||||
downloadDirMu.RLock()
|
||||
defer downloadDirMu.RUnlock()
|
||||
return downloadDir
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func getDownloadDir() string {
|
||||
// downloadDirMu.RLock()
|
||||
// defer downloadDirMu.RUnlock()
|
||||
// return downloadDir
|
||||
// }
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
@@ -256,7 +257,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
bytesInInterval := pw.current - pw.lastBytes
|
||||
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||
}
|
||||
|
||||
|
||||
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||
pw.lastReported = pw.current
|
||||
pw.lastTime = now
|
||||
|
||||
+469
-128
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QobuzDownloader handles Qobuz downloads
|
||||
@@ -52,59 +53,299 @@ type QobuzTrack struct {
|
||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
|
||||
// Split expected artists by common separators (comma, feat, ft., &, and)
|
||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
||||
expectedArtists := qobuzSplitArtists(normExpected)
|
||||
foundArtists := qobuzSplitArtists(normFound)
|
||||
|
||||
// Check if ANY expected artist matches ANY found artist
|
||||
for _, exp := range expectedArtists {
|
||||
for _, fnd := range foundArtists {
|
||||
if exp == fnd {
|
||||
return true
|
||||
}
|
||||
// Also check contains for partial matches
|
||||
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||
return true
|
||||
}
|
||||
// Check same words different order
|
||||
if qobuzSameWordsUnordered(exp, fnd) {
|
||||
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||
if expectedLatin != foundLatin {
|
||||
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||
func qobuzIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
// qobuzSplitArtists splits artist string by common separators
|
||||
func qobuzSplitArtists(artists string) []string {
|
||||
// Replace common separators with a standard one
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " & ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " and ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, ", ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " x ", "|")
|
||||
|
||||
parts := strings.Split(normalized, "|")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
|
||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
||||
func qobuzSameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
|
||||
// Must have same number of words
|
||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort and compare
|
||||
sortedA := make([]string, len(wordsA))
|
||||
sortedB := make([]string, len(wordsB))
|
||||
copy(sortedA, wordsA)
|
||||
copy(sortedB, wordsB)
|
||||
|
||||
// Simple bubble sort (usually just 2-3 words)
|
||||
for i := 0; i < len(sortedA)-1; i++ {
|
||||
for j := i + 1; j < len(sortedA); j++ {
|
||||
if sortedA[i] > sortedA[j] {
|
||||
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
|
||||
}
|
||||
if sortedB[i] > sortedB[j] {
|
||||
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sortedA {
|
||||
if sortedA[i] != sortedB[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// qobuzTitlesMatch checks if track titles are similar enough
|
||||
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
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
|
||||
// Kept for potential future use
|
||||
// func qobuzIsASCIIString(s string) bool {
|
||||
// for _, r := range s {
|
||||
// if r > 127 {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
// containsQueryQobuz checks if a query already exists in the list
|
||||
func containsQueryQobuz(queries []string, query string) bool {
|
||||
for _, q := range queries {
|
||||
@@ -132,8 +373,8 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
// Same APIs as PC version (referensi/backend/qobuz.go)
|
||||
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
||||
encodedAPIs := []string{
|
||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - 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)
|
||||
}
|
||||
|
||||
var apis []string
|
||||
@@ -194,6 +435,8 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||
|
||||
@@ -221,6 +464,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
return nil, err
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||
|
||||
// Find ISRC matches
|
||||
var isrcMatches []*QobuzTrack
|
||||
for i := range result.Tracks.Items {
|
||||
@@ -229,6 +474,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||
|
||||
if len(isrcMatches) > 0 {
|
||||
// Verify duration if provided
|
||||
if expectedDurationSec > 0 {
|
||||
@@ -238,27 +485,27 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
// Allow 30 seconds tolerance
|
||||
if durationDiff <= 30 {
|
||||
// Allow 10 seconds tolerance
|
||||
if durationDiff <= 10 {
|
||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -281,6 +528,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
||||
|
||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||
// 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) {
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
|
||||
@@ -312,7 +560,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||
if !containsQueryQobuz(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 +590,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -353,7 +601,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -374,7 +622,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
resp.Body.Close()
|
||||
|
||||
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...)
|
||||
}
|
||||
}
|
||||
@@ -383,16 +631,35 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
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 expectedDurationSec > 0 {
|
||||
var durationMatches []*QobuzTrack
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
for _, track := range tracksToCheck {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff <= 30 {
|
||||
if durationDiff <= 10 {
|
||||
durationMatches = append(durationMatches, track)
|
||||
}
|
||||
}
|
||||
@@ -401,105 +668,164 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
// Return best quality among duration matches
|
||||
for _, track := range durationMatches {
|
||||
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
|
||||
}
|
||||
}
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||
return durationMatches[0], nil
|
||||
}
|
||||
|
||||
// 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
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
// No duration verification, return best quality from title matches
|
||||
for _, track := range tracksToCheck {
|
||||
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 &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
|
||||
// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality}
|
||||
func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
// qobuzAPIResult holds the result from a parallel API request
|
||||
type qobuzAPIResult struct {
|
||||
apiURL string
|
||||
downloadURL string
|
||||
err error
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
retryConfig := DefaultRetryConfig()
|
||||
var errors []string
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
|
||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
|
||||
// Start all requests in parallel
|
||||
for _, apiURL := range apis {
|
||||
// All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality}
|
||||
// The apiURL already includes the path, just append trackID and quality
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
fmt.Printf("[Qobuz] Trying: %s\n", reqURL)
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||
continue
|
||||
}
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
|
||||
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
||||
if err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||
continue
|
||||
}
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
||||
continue
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check if response is HTML (error page)
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON"))
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for error in JSON response
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error))
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
// Check if response is HTML (error page)
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL)
|
||||
return apiURL, result.URL, nil
|
||||
}
|
||||
// Check for error in JSON response
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response"))
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
// Collect results - return first success
|
||||
var errors []string
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil {
|
||||
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
|
||||
|
||||
// Drain remaining results to avoid goroutine leaks
|
||||
go func(remaining int) {
|
||||
for j := 0; j < remaining; j++ {
|
||||
<-resultChan
|
||||
}
|
||||
}(len(apis) - i - 1)
|
||||
|
||||
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
||||
return result.apiURL, result.downloadURL, nil
|
||||
}
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
apis := q.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
return "", fmt.Errorf("no Qobuz API available")
|
||||
}
|
||||
|
||||
_, downloadURL, err := getQobuzDownloadURLSequential(apis, trackID, quality)
|
||||
// Use parallel approach - request from all APIs simultaneously
|
||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -592,6 +918,7 @@ type QobuzDownloadResult struct {
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// downloadFromQobuz downloads a track using the request parameters
|
||||
@@ -612,11 +939,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if req.ISRC != "" {
|
||||
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
|
||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -624,21 +951,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Strategy 1: Search by ISRC with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
// Verify artist
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
// Verify artist AND title
|
||||
if track != nil {
|
||||
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
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 {
|
||||
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) {
|
||||
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)
|
||||
track = nil
|
||||
}
|
||||
@@ -653,7 +987,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
@@ -687,12 +1021,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
case "HI_RES_LOSSLESS":
|
||||
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
|
||||
actualBitDepth := track.MaximumBitDepth
|
||||
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
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
@@ -731,11 +1065,17 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
|
||||
// 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{
|
||||
Title: track.Title,
|
||||
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
|
||||
Date: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
@@ -748,7 +1088,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
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 {
|
||||
@@ -757,9 +1097,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
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 {
|
||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
@@ -780,5 +1120,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||
ISRC: track.ISRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+92
-69
@@ -2,7 +2,6 @@ package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,14 +16,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
@@ -54,7 +53,7 @@ type SpotifyMetadataClient struct {
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
@@ -69,8 +68,10 @@ var (
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||
// Pass empty strings to use default credentials
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
@@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
customClientSecret = clientSecret
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials (custom or default)
|
||||
func getCredentials() (string, string) {
|
||||
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||
func HasSpotifyCredentials() bool {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
|
||||
// Check custom credentials first
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret
|
||||
}
|
||||
|
||||
// Fall back to default credentials
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
clientID = string(decoded)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
if clientSecret == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||
clientSecret = string(decoded)
|
||||
}
|
||||
// Check environment variables
|
||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return clientID, clientSecret
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials or error if not configured
|
||||
func getCredentials() (string, string, error) {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
// Check custom credentials first
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret, nil
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// No credentials available
|
||||
return "", "", ErrNoSpotifyCredentials
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
// Returns error if credentials are not configured
|
||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
// Get credentials - will error if not configured
|
||||
clientID, clientSecret, err := getCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get credentials (custom or default)
|
||||
clientID, clientSecret := getCredentials()
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||
@@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
}
|
||||
c.userAgent = c.randomUserAgent()
|
||||
return c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TrackMetadata represents track information
|
||||
@@ -140,6 +158,7 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
}
|
||||
|
||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
||||
@@ -159,6 +178,7 @@ type AlbumTrackMetadata struct {
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
}
|
||||
|
||||
// AlbumInfoMetadata holds album information
|
||||
@@ -283,6 +303,7 @@ type albumSimplified struct {
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
}
|
||||
|
||||
type trackFull struct {
|
||||
@@ -331,14 +352,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
||||
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -363,6 +384,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -373,7 +395,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
// Create cache key
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
@@ -388,24 +410,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
} `json:"tracks"`
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -430,6 +452,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -438,7 +461,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
if artistCount > artistLimit {
|
||||
artistCount = artistLimit
|
||||
}
|
||||
|
||||
|
||||
for i := 0; i < artistCount; i++ {
|
||||
artist := response.Artists.Items[i]
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
@@ -534,7 +557,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
// Collect all tracks (including paginated)
|
||||
allTrackItems := data.Tracks.Items
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
|
||||
// Fetch remaining tracks using pagination (no limit)
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
@@ -563,7 +586,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||
for _, item := range allTrackItems {
|
||||
isrc := isrcMap[item.ID]
|
||||
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: item.ID,
|
||||
Artists: joinArtists(item.Artists),
|
||||
@@ -602,23 +625,23 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
// Similar to Deezer implementation for consistency
|
||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
|
||||
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
|
||||
|
||||
if len(trackIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, maxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for _, trackID := range trackIDs {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
@@ -626,15 +649,15 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||
|
||||
|
||||
resultMu.Lock()
|
||||
result[id] = isrc
|
||||
resultMu.Unlock()
|
||||
}(trackID)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
@@ -668,7 +691,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
|
||||
// Pre-allocate with expected capacity
|
||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||
|
||||
|
||||
// Add first batch of tracks
|
||||
for _, item := range data.Tracks.Items {
|
||||
if item.Track == nil {
|
||||
@@ -695,7 +718,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
|
||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []struct {
|
||||
@@ -755,10 +778,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
|
||||
// Fetch artist info
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
@@ -941,15 +964,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
// Use Mac User-Agent format (same as PC version)
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
|
||||
+547
-223
File diff suppressed because it is too large
Load Diff
@@ -181,6 +181,13 @@ import Gobackend // Import Go framework
|
||||
GobackendCleanupConnections()
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
@@ -242,6 +249,315 @@ import Gobackend // Import Go framework
|
||||
GobackendClearTrackCache()
|
||||
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
|
||||
|
||||
case "hasSpotifyCredentials":
|
||||
let hasCredentials = GobackendCheckSpotifyCredentials()
|
||||
return hasCredentials
|
||||
|
||||
// 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
|
||||
|
||||
// Extension System methods
|
||||
case "initExtensionSystem":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionsDir = args["extensions_dir"] as! String
|
||||
let dataDir = args["data_dir"] as! String
|
||||
GobackendInitExtensionSystem(extensionsDir, dataDir, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "loadExtensionsFromDir":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let dirPath = args["dir_path"] as! String
|
||||
let response = GobackendLoadExtensionsFromDir(dirPath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "loadExtensionFromPath":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendLoadExtensionFromPath(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "unloadExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
GobackendUnloadExtensionByID(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getInstalledExtensions":
|
||||
let response = GobackendGetInstalledExtensions(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setExtensionEnabled":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let enabled = args["enabled"] as? Bool ?? false
|
||||
GobackendSetExtensionEnabledByID(extensionId, enabled, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "setProviderPriority":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let priorityJson = args["priority"] as! String
|
||||
GobackendSetProviderPriorityJSON(priorityJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getProviderPriority":
|
||||
let response = GobackendGetProviderPriorityJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setMetadataProviderPriority":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let priorityJson = args["priority"] as! String
|
||||
GobackendSetMetadataProviderPriorityJSON(priorityJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getMetadataProviderPriority":
|
||||
let response = GobackendGetMetadataProviderPriorityJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getExtensionSettings":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setExtensionSettings":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let settingsJson = args["settings"] as! String
|
||||
GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "searchTracksWithExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let limit = args["limit"] as? Int ?? 20
|
||||
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadWithExtensions":
|
||||
let requestJson = call.arguments as! String
|
||||
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "removeExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
GobackendRemoveExtensionByID(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "upgradeExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendUpgradeExtensionFromPath(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkExtensionUpgrade":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "cleanupExtensions":
|
||||
GobackendCleanupExtensions()
|
||||
return nil
|
||||
|
||||
// Extension Auth API
|
||||
case "getExtensionPendingAuth":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setExtensionAuthCode":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let authCode = args["auth_code"] as! String
|
||||
GobackendSetExtensionAuthCodeByID(extensionId, authCode)
|
||||
return nil
|
||||
|
||||
case "setExtensionTokens":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let accessToken = args["access_token"] as! String
|
||||
let refreshToken = args["refresh_token"] as? String ?? ""
|
||||
let expiresIn = args["expires_in"] as? Int ?? 0
|
||||
GobackendSetExtensionTokensByID(extensionId, accessToken, refreshToken, Int(expiresIn))
|
||||
return nil
|
||||
|
||||
case "clearExtensionPendingAuth":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
GobackendClearExtensionPendingAuthByID(extensionId)
|
||||
return nil
|
||||
|
||||
case "isExtensionAuthenticated":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendIsExtensionAuthenticatedByID(extensionId)
|
||||
return response
|
||||
|
||||
case "getAllPendingAuthRequests":
|
||||
let response = GobackendGetAllPendingAuthRequestsJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension FFmpeg API
|
||||
case "getPendingFFmpegCommand":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let commandId = args["command_id"] as! String
|
||||
let response = GobackendGetPendingFFmpegCommandJSON(commandId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setFFmpegCommandResult":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let commandId = args["command_id"] as! String
|
||||
let success = args["success"] as? Bool ?? false
|
||||
let output = args["output"] as? String ?? ""
|
||||
let errorMsg = args["error"] as? String ?? ""
|
||||
GobackendSetFFmpegCommandResult(commandId, success, output, errorMsg)
|
||||
return nil
|
||||
|
||||
case "getAllPendingFFmpegCommands":
|
||||
let response = GobackendGetAllPendingFFmpegCommandsJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Custom Search API
|
||||
case "customSearchWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let query = args["query"] as! String
|
||||
let optionsJson = args["options"] as? String ?? ""
|
||||
let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getSearchProviders":
|
||||
let response = GobackendGetSearchProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension URL Handler API
|
||||
case "handleURLWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendHandleURLWithExtensionJSON(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "findURLHandler":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendFindURLHandlerJSON(url)
|
||||
return response
|
||||
|
||||
case "getURLHandlers":
|
||||
let response = GobackendGetURLHandlersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Post-Processing API
|
||||
case "runPostProcessing":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let metadataJson = args["metadata"] as? String ?? ""
|
||||
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPostProcessingProviders":
|
||||
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Store
|
||||
case "initExtensionStore":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let cacheDir = args["cache_dir"] as! String
|
||||
GobackendInitExtensionStoreJSON(cacheDir, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getStoreExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchStoreExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as? String ?? ""
|
||||
let category = args["category"] as? String ?? ""
|
||||
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getStoreCategories":
|
||||
let response = GobackendGetStoreCategoriesJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadStoreExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let destDir = args["dest_dir"] as! String
|
||||
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "clearStoreCache":
|
||||
GobackendClearStoreCacheJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '2.1.7';
|
||||
static const String buildNumber = '45';
|
||||
static const String version = '3.0.0';
|
||||
static const String buildNumber = '57';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
+34
-3
@@ -1,7 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
|
||||
@@ -24,14 +27,42 @@ void main() async {
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerWidget {
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeExtensions();
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
// Create directories if needed
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
// Initialize extension system
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Eagerly initialize download history provider to load from storage
|
||||
ref.watch(downloadHistoryProvider);
|
||||
return child;
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ enum DownloadErrorType {
|
||||
notFound, // Track not found on any service
|
||||
rateLimit, // Rate limited by service
|
||||
network, // Network/connection error
|
||||
permission, // File/folder permission error
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -88,6 +89,8 @@ class DownloadItem {
|
||||
return 'Rate limit reached, try again later';
|
||||
case DownloadErrorType.network:
|
||||
return 'Connection failed, check your internet';
|
||||
case DownloadErrorType.permission:
|
||||
return 'Cannot write to folder, check storage permission';
|
||||
default:
|
||||
return error ?? 'An error occurred';
|
||||
}
|
||||
|
||||
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
|
||||
DownloadErrorType.notFound: 'notFound',
|
||||
DownloadErrorType.rateLimit: 'rateLimit',
|
||||
DownloadErrorType.network: 'network',
|
||||
DownloadErrorType.permission: 'permission',
|
||||
};
|
||||
|
||||
@@ -18,14 +18,21 @@ class AppSettings {
|
||||
final bool hasSearchedBefore; // Hide helper text after first search
|
||||
final String folderOrganization; // none, artist, album, artist_album
|
||||
final String historyViewMode; // list, grid
|
||||
final String historyFilterMode; // all, albums, singles
|
||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
final bool enableLogging; // Enable detailed logging for debugging
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
final String albumFolderStructure; // artist_album or album_only
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'qobuz',
|
||||
this.defaultService = 'tidal',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.filenameFormat = '{title} - {artist}',
|
||||
this.downloadDirectory = '',
|
||||
@@ -39,11 +46,18 @@ class AppSettings {
|
||||
this.hasSearchedBefore = false, // Default: show helper text
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.historyFilterMode = 'all', // Default: show all
|
||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
this.enableLogging = false, // Default: disabled for performance
|
||||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
this.separateSingles = false, // Default: disabled
|
||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -61,11 +75,19 @@ class AppSettings {
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
bool? useCustomSpotifyCredentials,
|
||||
String? metadataSource,
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -82,11 +104,18 @@ class AppSettings {
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,19 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||
useCustomSpotifyCredentials:
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -45,9 +52,16 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ class Track {
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -33,10 +35,18 @@ class Track {
|
||||
this.releaseDate,
|
||||
this.deezerId,
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
});
|
||||
|
||||
/// Check if this track is a single (based on album_type metadata)
|
||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
/// Check if this track is from an extension
|
||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -24,6 +24,8 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
: ServiceAvailability.fromJson(
|
||||
json['availability'] as Map<String, dynamic>,
|
||||
),
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -40,6 +42,8 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'releaseDate': instance.releaseDate,
|
||||
'deezerId': instance.deezerId,
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
};
|
||||
|
||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
/// Represents an installed extension
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final bool enabled;
|
||||
final String status; // 'loaded', 'error', 'disabled'
|
||||
final String? errorMessage;
|
||||
final String? iconPath; // Path to extension icon
|
||||
final List<String> permissions;
|
||||
final List<ExtensionSetting> settings;
|
||||
final List<QualityOption> qualityOptions; // Custom quality options for download providers
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior; // Custom search behavior
|
||||
final URLHandler? urlHandler; // Custom URL handling
|
||||
final TrackMatching? trackMatching; // Custom track matching
|
||||
final PostProcessing? postProcessing; // Post-processing hooks
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
required this.status,
|
||||
this.errorMessage,
|
||||
this.iconPath,
|
||||
this.permissions = const [],
|
||||
this.settings = const [],
|
||||
this.qualityOptions = const [],
|
||||
this.hasMetadataProvider = false,
|
||||
this.hasDownloadProvider = false,
|
||||
this.skipMetadataEnrichment = false,
|
||||
this.searchBehavior,
|
||||
this.urlHandler,
|
||||
this.trackMatching,
|
||||
this.postProcessing,
|
||||
});
|
||||
|
||||
factory Extension.fromJson(Map<String, dynamic> json) {
|
||||
return Extension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
status: json['status'] as String? ?? 'loaded',
|
||||
errorMessage: json['error_message'] as String?,
|
||||
iconPath: json['icon_path'] as String?,
|
||||
permissions: (json['permissions'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
settings: (json['settings'] as List<dynamic>?)
|
||||
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
qualityOptions: (json['quality_options'] as List<dynamic>?)
|
||||
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
||||
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
|
||||
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
|
||||
searchBehavior: json['search_behavior'] != null
|
||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||
: null,
|
||||
urlHandler: json['url_handler'] != null
|
||||
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
|
||||
: null,
|
||||
trackMatching: json['track_matching'] != null
|
||||
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
|
||||
: null,
|
||||
postProcessing: json['post_processing'] != null
|
||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Extension copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? displayName,
|
||||
String? version,
|
||||
String? author,
|
||||
String? description,
|
||||
bool? enabled,
|
||||
String? status,
|
||||
String? errorMessage,
|
||||
String? iconPath,
|
||||
List<String>? permissions,
|
||||
List<ExtensionSetting>? settings,
|
||||
List<QualityOption>? qualityOptions,
|
||||
bool? hasMetadataProvider,
|
||||
bool? hasDownloadProvider,
|
||||
bool? skipMetadataEnrichment,
|
||||
SearchBehavior? searchBehavior,
|
||||
URLHandler? urlHandler,
|
||||
TrackMatching? trackMatching,
|
||||
PostProcessing? postProcessing,
|
||||
}) {
|
||||
return Extension(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
displayName: displayName ?? this.displayName,
|
||||
version: version ?? this.version,
|
||||
author: author ?? this.author,
|
||||
description: description ?? this.description,
|
||||
enabled: enabled ?? this.enabled,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
iconPath: iconPath ?? this.iconPath,
|
||||
permissions: permissions ?? this.permissions,
|
||||
settings: settings ?? this.settings,
|
||||
qualityOptions: qualityOptions ?? this.qualityOptions,
|
||||
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
postProcessing: postProcessing ?? this.postProcessing,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
|
||||
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
}
|
||||
|
||||
/// Custom search behavior configuration
|
||||
class SearchBehavior {
|
||||
final bool enabled;
|
||||
final String? placeholder;
|
||||
final bool primary;
|
||||
final String? icon;
|
||||
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
this.placeholder,
|
||||
this.primary = false,
|
||||
this.icon,
|
||||
this.thumbnailRatio,
|
||||
this.thumbnailWidth,
|
||||
this.thumbnailHeight,
|
||||
});
|
||||
|
||||
factory SearchBehavior.fromJson(Map<String, dynamic> json) {
|
||||
return SearchBehavior(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
placeholder: json['placeholder'] as String?,
|
||||
primary: json['primary'] as bool? ?? false,
|
||||
icon: json['icon'] as String?,
|
||||
thumbnailRatio: json['thumbnailRatio'] as String?,
|
||||
thumbnailWidth: json['thumbnailWidth'] as int?,
|
||||
thumbnailHeight: json['thumbnailHeight'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get thumbnail size based on configuration
|
||||
/// Returns (width, height) tuple
|
||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||
// If custom dimensions specified, use them
|
||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||
}
|
||||
|
||||
// Otherwise use ratio presets
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
case 'portrait': // 2:3 - Poster style
|
||||
return (defaultSize * 2 / 3, defaultSize);
|
||||
case 'square': // 1:1 - Album art style
|
||||
default:
|
||||
return (defaultSize, defaultSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom track matching configuration
|
||||
class TrackMatching {
|
||||
final bool customMatching;
|
||||
final String? strategy; // "isrc", "name", "duration", "custom"
|
||||
final int durationTolerance; // in seconds
|
||||
|
||||
const TrackMatching({
|
||||
required this.customMatching,
|
||||
this.strategy,
|
||||
this.durationTolerance = 3,
|
||||
});
|
||||
|
||||
factory TrackMatching.fromJson(Map<String, dynamic> json) {
|
||||
return TrackMatching(
|
||||
customMatching: json['customMatching'] as bool? ?? false,
|
||||
strategy: json['strategy'] as String?,
|
||||
durationTolerance: json['durationTolerance'] as int? ?? 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-processing configuration
|
||||
class PostProcessing {
|
||||
final bool enabled;
|
||||
final List<PostProcessingHook> hooks;
|
||||
|
||||
const PostProcessing({
|
||||
required this.enabled,
|
||||
this.hooks = const [],
|
||||
});
|
||||
|
||||
factory PostProcessing.fromJson(Map<String, dynamic> json) {
|
||||
return PostProcessing(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
hooks: (json['hooks'] as List<dynamic>?)
|
||||
?.map((h) => PostProcessingHook.fromJson(h as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// URL handler configuration for custom URL patterns
|
||||
class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
|
||||
const URLHandler({
|
||||
required this.enabled,
|
||||
this.patterns = const [],
|
||||
});
|
||||
|
||||
factory URLHandler.fromJson(Map<String, dynamic> json) {
|
||||
return URLHandler(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
patterns: (json['patterns'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a URL matches any of the patterns
|
||||
bool matchesURL(String url) {
|
||||
if (!enabled || patterns.isEmpty) return false;
|
||||
final lowerUrl = url.toLowerCase();
|
||||
for (final pattern in patterns) {
|
||||
if (lowerUrl.contains(pattern.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A post-processing hook
|
||||
class PostProcessingHook {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final bool defaultEnabled;
|
||||
final List<String> supportedFormats;
|
||||
|
||||
const PostProcessingHook({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.defaultEnabled = false,
|
||||
this.supportedFormats = const [],
|
||||
});
|
||||
|
||||
factory PostProcessingHook.fromJson(Map<String, dynamic> json) {
|
||||
return PostProcessingHook(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
defaultEnabled: json['defaultEnabled'] as bool? ?? false,
|
||||
supportedFormats: (json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a quality option for download providers
|
||||
class QualityOption {
|
||||
final String id;
|
||||
final String label;
|
||||
final String? description;
|
||||
final List<QualitySpecificSetting> settings; // Quality-specific settings
|
||||
|
||||
const QualityOption({
|
||||
required this.id,
|
||||
required this.label,
|
||||
this.description,
|
||||
this.settings = const [],
|
||||
});
|
||||
|
||||
factory QualityOption.fromJson(Map<String, dynamic> json) {
|
||||
return QualityOption(
|
||||
id: json['id'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
settings: (json['settings'] as List<dynamic>?)
|
||||
?.map((s) => QualitySpecificSetting.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting that's specific to a quality option
|
||||
class QualitySpecificSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final bool required;
|
||||
final bool secret;
|
||||
|
||||
const QualitySpecificSetting({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.type,
|
||||
this.defaultValue,
|
||||
this.description,
|
||||
this.options,
|
||||
this.required = false,
|
||||
this.secret = false,
|
||||
});
|
||||
|
||||
factory QualitySpecificSetting.fromJson(Map<String, dynamic> json) {
|
||||
return QualitySpecificSetting(
|
||||
key: json['key'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'string',
|
||||
defaultValue: json['default'],
|
||||
description: json['description'] as String?,
|
||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||
required: json['required'] as bool? ?? false,
|
||||
secret: json['secret'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting field for an extension
|
||||
class ExtensionSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final bool required;
|
||||
|
||||
const ExtensionSetting({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.type,
|
||||
this.defaultValue,
|
||||
this.description,
|
||||
this.options,
|
||||
this.required = false,
|
||||
});
|
||||
|
||||
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
||||
return ExtensionSetting(
|
||||
key: json['key'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'string',
|
||||
defaultValue: json['default'],
|
||||
description: json['description'] as String?,
|
||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||
required: json['required'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension management
|
||||
class ExtensionState {
|
||||
final List<Extension> extensions;
|
||||
final List<String> providerPriority;
|
||||
final List<String> metadataProviderPriority;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final bool isInitialized;
|
||||
|
||||
const ExtensionState({
|
||||
this.extensions = const [],
|
||||
this.providerPriority = const [],
|
||||
this.metadataProviderPriority = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.isInitialized = false,
|
||||
});
|
||||
|
||||
ExtensionState copyWith({
|
||||
List<Extension>? extensions,
|
||||
List<String>? providerPriority,
|
||||
List<String>? metadataProviderPriority,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
bool? isInitialized,
|
||||
}) {
|
||||
return ExtensionState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Provider for managing extensions
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
@override
|
||||
ExtensionState build() {
|
||||
return const ExtensionState();
|
||||
}
|
||||
|
||||
/// Initialize the extension system
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||
await loadExtensions(extensionsDir);
|
||||
await loadProviderPriority();
|
||||
await loadMetadataProviderPriority();
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension system initialized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize extension system: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all extensions from directory
|
||||
Future<void> loadExtensions(String dirPath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
|
||||
_log.d('Load extensions result: $result');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load extensions: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the list of installed extensions
|
||||
Future<void> refreshExtensions() async {
|
||||
try {
|
||||
final list = await PlatformBridge.getInstalledExtensions();
|
||||
final extensions = list.map((e) => Extension.fromJson(e)).toList();
|
||||
state = state.copyWith(extensions: extensions);
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
// Log search behavior for extensions that have it
|
||||
for (final ext in extensions) {
|
||||
if (ext.searchBehavior != null) {
|
||||
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to refresh extensions: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any error state
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
/// Install extension from file (auto-upgrades if already installed with newer version)
|
||||
Future<bool> installExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionFromPath(filePath);
|
||||
_log.i('Installed extension: ${result['name']}');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to install extension: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a package file is an upgrade for an existing extension
|
||||
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
|
||||
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
try {
|
||||
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
||||
} catch (e) {
|
||||
_log.e('Failed to check extension upgrade: $e');
|
||||
return {'error': e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade an existing extension from a new package file
|
||||
Future<bool> upgradeExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.upgradeExtension(filePath);
|
||||
_log.i('Upgraded extension: ${result['display_name']} to v${result['version']}');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to upgrade extension: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall/remove an extension
|
||||
Future<bool> removeExtension(String extensionId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await PlatformBridge.removeExtension(extensionId);
|
||||
_log.i('Removed extension: $extensionId');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to remove extension: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable an extension
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
_log.d('Set extension $extensionId enabled: $enabled');
|
||||
|
||||
// Get extension info before updating state
|
||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
|
||||
// Update local state
|
||||
final extensions = state.extensions.map((e) {
|
||||
if (e.id == extensionId) {
|
||||
return e.copyWith(enabled: enabled);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
|
||||
// If disabling an extension, reset related settings
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// If this extension was the search provider, clear it and reset to Deezer
|
||||
if (settings.searchProvider == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||
}
|
||||
|
||||
// If this extension was the default download service, reset to Tidal
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to set extension enabled: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get settings for an extension
|
||||
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
try {
|
||||
return await PlatformBridge.getExtensionSettings(extensionId);
|
||||
} catch (e) {
|
||||
_log.e('Failed to get extension settings: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Update settings for an extension
|
||||
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
||||
_log.d('Updated settings for extension: $extensionId');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set extension settings: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load provider priority order
|
||||
Future<void> loadProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getProviderPriority();
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Set provider priority order
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
_log.d('Updated provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load metadata provider priority order
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load metadata provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Set metadata provider priority order
|
||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
_log.d('Updated metadata provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set metadata provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup all extensions (call on app close)
|
||||
Future<void> cleanup() async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up');
|
||||
} catch (e) {
|
||||
_log.e('Failed to cleanup extensions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension by ID
|
||||
Extension? getExtension(String extensionId) {
|
||||
try {
|
||||
return state.extensions.firstWhere((ext) => ext.id == extensionId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all enabled extensions
|
||||
List<Extension> get enabledExtensions {
|
||||
return state.extensions.where((ext) => ext.enabled).toList();
|
||||
}
|
||||
|
||||
/// Get all download providers (built-in + extensions)
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'amazon'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/// Get all metadata providers (built-in + extensions)
|
||||
List<String> getAllMetadataProviders() {
|
||||
final providers = ['deezer', 'spotify'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasMetadataProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
/// Get all extensions that provide custom search
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
|
||||
ExtensionNotifier.new,
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
@@ -26,6 +27,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
// Apply Spotify credentials to Go backend on load
|
||||
_applySpotifyCredentials();
|
||||
|
||||
// Sync logging state
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,18 +60,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
/// Apply current Spotify credentials to Go backend
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
// Only apply custom credentials if enabled and both fields are set
|
||||
if (state.useCustomSpotifyCredentials &&
|
||||
state.spotifyClientId.isNotEmpty &&
|
||||
// Only apply if both fields are set
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
state.spotifyClientSecret,
|
||||
);
|
||||
} else {
|
||||
// Clear to use default
|
||||
await PlatformBridge.setSpotifyCredentials('', '');
|
||||
}
|
||||
// Note: If credentials are empty, Spotify API will return error
|
||||
// User should use Deezer as metadata source instead
|
||||
}
|
||||
|
||||
void setDefaultService(String service) {
|
||||
@@ -144,6 +146,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryFilterMode(String mode) {
|
||||
state = state.copyWith(historyFilterMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAskQualityBeforeDownload(bool enabled) {
|
||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||
_saveSettings();
|
||||
@@ -187,6 +194,42 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSearchProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearSearchProvider: true);
|
||||
} else {
|
||||
state = state.copyWith(searchProvider: provider);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
// Sync logging state to LogBuffer
|
||||
LogBuffer.loggingEnabled = enabled;
|
||||
}
|
||||
|
||||
void setUseExtensionProviders(bool enabled) {
|
||||
state = state.copyWith(useExtensionProviders: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSeparateSingles(bool enabled) {
|
||||
state = state.copyWith(separateSingles: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAlbumFolderStructure(String structure) {
|
||||
state = state.copyWith(albumFolderStructure: structure);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setShowExtensionStore(bool enabled) {
|
||||
state = state.copyWith(showExtensionStore: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('StoreProvider');
|
||||
|
||||
/// Extension categories
|
||||
class StoreCategory {
|
||||
static const String metadata = 'metadata';
|
||||
static const String download = 'download';
|
||||
static const String utility = 'utility';
|
||||
static const String lyrics = 'lyrics';
|
||||
static const String integration = 'integration';
|
||||
|
||||
static const List<String> all = [metadata, download, utility, lyrics, integration];
|
||||
|
||||
static String getDisplayName(String category) {
|
||||
switch (category) {
|
||||
case metadata:
|
||||
return 'Metadata';
|
||||
case download:
|
||||
return 'Download';
|
||||
case utility:
|
||||
return 'Utility';
|
||||
case lyrics:
|
||||
return 'Lyrics';
|
||||
case integration:
|
||||
return 'Integration';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an extension in the store
|
||||
class StoreExtension {
|
||||
final String id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final String downloadUrl;
|
||||
final String? iconUrl;
|
||||
final String category;
|
||||
final List<String> tags;
|
||||
final int downloads;
|
||||
final String updatedAt;
|
||||
final String? minAppVersion;
|
||||
final bool isInstalled;
|
||||
final String? installedVersion;
|
||||
final bool hasUpdate;
|
||||
|
||||
const StoreExtension({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.downloadUrl,
|
||||
this.iconUrl,
|
||||
required this.category,
|
||||
this.tags = const [],
|
||||
this.downloads = 0,
|
||||
required this.updatedAt,
|
||||
this.minAppVersion,
|
||||
this.isInstalled = false,
|
||||
this.installedVersion,
|
||||
this.hasUpdate = false,
|
||||
});
|
||||
|
||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||
return StoreExtension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
downloadUrl: json['download_url'] as String? ?? '',
|
||||
iconUrl: json['icon_url'] as String?,
|
||||
category: json['category'] as String? ?? 'utility',
|
||||
tags: (json['tags'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
downloads: json['downloads'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as String? ?? '',
|
||||
minAppVersion: json['min_app_version'] as String?,
|
||||
isInstalled: json['is_installed'] as bool? ?? false,
|
||||
installedVersion: json['installed_version'] as String?,
|
||||
hasUpdate: json['has_update'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension store
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
final String searchQuery;
|
||||
final bool isLoading;
|
||||
final bool isDownloading;
|
||||
final String? downloadingId;
|
||||
final String? error;
|
||||
final bool isInitialized;
|
||||
|
||||
const StoreState({
|
||||
this.extensions = const [],
|
||||
this.selectedCategory,
|
||||
this.searchQuery = '',
|
||||
this.isLoading = false,
|
||||
this.isDownloading = false,
|
||||
this.downloadingId,
|
||||
this.error,
|
||||
this.isInitialized = false,
|
||||
});
|
||||
|
||||
StoreState copyWith({
|
||||
List<StoreExtension>? extensions,
|
||||
String? selectedCategory,
|
||||
bool clearCategory = false,
|
||||
String? searchQuery,
|
||||
bool? isLoading,
|
||||
bool? isDownloading,
|
||||
String? downloadingId,
|
||||
bool clearDownloadingId = false,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
bool? isInitialized,
|
||||
}) {
|
||||
return StoreState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isDownloading: isDownloading ?? this.isDownloading,
|
||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get filtered extensions based on category and search
|
||||
List<StoreExtension> get filteredExtensions {
|
||||
var result = extensions;
|
||||
|
||||
if (selectedCategory != null) {
|
||||
result = result.where((e) => e.category == selectedCategory).toList();
|
||||
}
|
||||
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery.toLowerCase();
|
||||
result = result.where((e) =>
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
||||
).toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing extension store
|
||||
class StoreNotifier extends Notifier<StoreState> {
|
||||
@override
|
||||
StoreState build() {
|
||||
return const StoreState();
|
||||
}
|
||||
|
||||
/// Initialize the store
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionStore(cacheDir);
|
||||
await refresh();
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension store initialized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh extensions from store
|
||||
Future<void> refresh({bool forceRefresh = false}) async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
||||
state = state.copyWith(
|
||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||
isLoading: false,
|
||||
);
|
||||
_log.d('Loaded ${state.extensions.length} extensions from store');
|
||||
} catch (e) {
|
||||
_log.e('Failed to refresh store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Set category filter
|
||||
void setCategory(String? category) {
|
||||
if (category == null) {
|
||||
state = state.copyWith(clearCategory: true);
|
||||
} else {
|
||||
state = state.copyWith(selectedCategory: category);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set search query
|
||||
void setSearchQuery(String query) {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
|
||||
/// Clear search
|
||||
void clearSearch() {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
|
||||
/// Download and install extension
|
||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
try {
|
||||
_log.i('Downloading extension: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
|
||||
_log.i('Installing extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
final success = await extNotifier.installExtension(downloadPath);
|
||||
|
||||
if (success) {
|
||||
_log.i('Extension installed: $extensionId');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to install extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an installed extension
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
try {
|
||||
_log.i('Downloading update for: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
|
||||
_log.i('Upgrading extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
final success = await extNotifier.upgradeExtension(downloadPath);
|
||||
|
||||
if (success) {
|
||||
_log.i('Extension updated: $extensionId');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to update extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
}
|
||||
|
||||
final storeProvider = NotifierProvider<StoreNotifier, StoreState>(
|
||||
StoreNotifier.new,
|
||||
);
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('TrackProvider');
|
||||
|
||||
class TrackState {
|
||||
final List<Track> tracks;
|
||||
@@ -15,6 +20,7 @@ class TrackState {
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final bool hasSearchText; // For back button handling
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -29,6 +35,7 @@ class TrackState {
|
||||
this.artistAlbums,
|
||||
this.searchArtists,
|
||||
this.hasSearchText = false,
|
||||
this.searchExtensionId,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||
@@ -46,6 +53,7 @@ class TrackState {
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
List<SearchArtist>? searchArtists,
|
||||
bool? hasSearchText,
|
||||
String? searchExtensionId,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -60,6 +68,7 @@ class TrackState {
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
searchArtists: searchArtists ?? this.searchArtists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
searchExtensionId: searchExtensionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +131,59 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// First, check if any extension can handle this URL
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: result['album']?['id'] as String?,
|
||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No extension handler found, try Spotify URL parsing
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
@@ -207,31 +269,109 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// Check if extension providers should be used for search
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
// Use Deezer or Spotify based on settings
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
// Debug log to show which source is being used
|
||||
// ignore: avoid_print
|
||||
print('[Search] Using metadata source: $source for query: "$query"');
|
||||
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
|
||||
|
||||
Map<String, dynamic> results;
|
||||
if (source == 'deezer') {
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||
// ignore: avoid_print
|
||||
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||
} else {
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
// ignore: avoid_print
|
||||
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||
List<Track> extensionTracks = [];
|
||||
|
||||
// Try extension providers first if enabled
|
||||
if (useExtensions) {
|
||||
try {
|
||||
_log.d('Calling extension search API...');
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||
_log.i('Extensions returned ${extResults.length} tracks');
|
||||
|
||||
// Parse extension results
|
||||
for (final t in extResults) {
|
||||
try {
|
||||
extensionTracks.add(_parseSearchTrack(t));
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse extension track: $e', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Extension search failed, falling back to built-in: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
// Also search with built-in providers
|
||||
if (source == 'deezer') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
} else {
|
||||
_log.d('Calling Spotify search API...');
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||
|
||||
// Parse tracks with error handling per item
|
||||
final tracks = <Track>[];
|
||||
|
||||
// Add extension tracks first (they have priority)
|
||||
tracks.addAll(extensionTracks);
|
||||
|
||||
// Add built-in provider tracks, avoiding duplicates by ISRC
|
||||
final existingIsrcs = extensionTracks
|
||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||
.map((t) => t.isrc!)
|
||||
.toSet();
|
||||
|
||||
for (int i = 0; i < trackList.length; i++) {
|
||||
final t = trackList[i];
|
||||
try {
|
||||
if (t is Map<String, dynamic>) {
|
||||
final track = _parseSearchTrack(t);
|
||||
// Skip if we already have this track from extensions
|
||||
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
|
||||
continue;
|
||||
}
|
||||
tracks.add(track);
|
||||
} 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 (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully');
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
@@ -239,9 +379,56 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
// Preserve hasSearchText on error so user stays on search screen
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform custom search using a specific extension
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve hasSearchText during search
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.i('Custom search returned ${results.length} tracks');
|
||||
|
||||
// Parse tracks with error handling per item, setting source to extension ID
|
||||
final tracks = <Track>[];
|
||||
for (int i = 0; i < results.length; i++) {
|
||||
final t = results[i];
|
||||
try {
|
||||
tracks.add(_parseSearchTrack(t, source: extensionId));
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse custom search track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: [], // Custom search doesn't return artists
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Custom search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
}
|
||||
}
|
||||
@@ -309,19 +496,30 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseSearchTrack(Map<String, dynamic> data) {
|
||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||
// 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(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
coverUrl: data['images'] as String?,
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
coverUrl: data['images']?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+20
-218
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Simple in-memory cache for album tracks
|
||||
class _AlbumCache {
|
||||
@@ -316,10 +317,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -331,84 +338,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
@@ -473,148 +417,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _AlbumTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
|
||||
@@ -159,6 +159,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
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(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
@@ -169,8 +174,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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),
|
||||
if (hasValidImage)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
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))],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 280,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+248
-253
@@ -8,11 +8,14 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_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/models/download_item.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
@@ -77,12 +80,31 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
// Skip if same query already searched
|
||||
if (_lastSearchQuery == query) return;
|
||||
_lastSearchQuery = query;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
|
||||
// Skip if same query already searched with same provider
|
||||
final searchKey = '${searchProvider ?? 'default'}:$query';
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
|
||||
// Check if extension search provider is set AND still enabled
|
||||
final isExtensionEnabled = searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
// Use custom search from extension
|
||||
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
||||
} else {
|
||||
// Use default search (Deezer/Spotify)
|
||||
// Also clear searchProvider if it was set but extension is disabled
|
||||
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
@@ -172,10 +194,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -183,87 +211,103 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||
// Show loading dialog with progress
|
||||
int currentProgress = 0;
|
||||
int totalTracks = 0;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
// Use StatefulBuilder to update dialog content
|
||||
bool dialogShown = false;
|
||||
StateSetter? setDialogState;
|
||||
|
||||
void showProgressDialog() {
|
||||
if (dialogShown || !mounted) return;
|
||||
dialogShown = true;
|
||||
showDialog(
|
||||
context: this.context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogCtx) => StatefulBuilder(
|
||||
builder: (dialogCtx, 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...',
|
||||
),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'FLAC Lossless',
|
||||
subtitle: '16-bit / 44.1kHz',
|
||||
icon: Icons.music_note,
|
||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC',
|
||||
subtitle: '24-bit / up to 96kHz',
|
||||
icon: Icons.high_quality,
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC Max',
|
||||
subtitle: '24-bit / up to 192kHz',
|
||||
icon: Icons.four_k,
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); },
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(this.context).pop();
|
||||
}
|
||||
|
||||
if (tracks.isNotEmpty) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Optionally show confirmation dialog
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: this.context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Import Playlist'),
|
||||
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||
child: const Text('Import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.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
|
||||
@@ -286,9 +330,14 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
|
||||
// Watch extension state to update search hint when extensions load/change
|
||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
|
||||
return Scaffold(
|
||||
@@ -297,24 +346,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
slivers: [
|
||||
// App Bar - always present
|
||||
SliverAppBar(
|
||||
expandedHeight: 130,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Home',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
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);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
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 +386,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
children: [
|
||||
SizedBox(height: screenHeight * 0.06),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
color: colorScheme.primary,
|
||||
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),
|
||||
Text(
|
||||
@@ -651,6 +723,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
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(
|
||||
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
||||
child: Container(
|
||||
@@ -666,12 +743,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: artist.imageUrl != null
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: artist.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 200,
|
||||
memCacheHeight: 200,
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.person,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 44,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
||||
),
|
||||
@@ -703,6 +785,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
|
||||
/// Get search hint based on selected provider
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final extState = ref.read(extensionProvider);
|
||||
|
||||
// If extension system not initialized yet, show default hint
|
||||
if (!extState.isInitialized) {
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull;
|
||||
// Only show extension placeholder if extension exists AND is enabled
|
||||
if (ext != null && ext.enabled) {
|
||||
if (ext.searchBehavior?.placeholder != null) {
|
||||
return ext.searchBehavior!.placeholder!;
|
||||
}
|
||||
return 'Search with ${ext.displayName}...';
|
||||
}
|
||||
// Extension not found or disabled - clear the search provider setting
|
||||
// and return default hint
|
||||
}
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||
final hasText = _urlController.text.isNotEmpty;
|
||||
|
||||
@@ -711,7 +819,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
hintText: _getSearchHint(),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
@@ -736,12 +844,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
onPressed: _clearAndRefresh,
|
||||
tooltip: 'Clear',
|
||||
)
|
||||
else
|
||||
else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_upload_outlined),
|
||||
onPressed: () => _importCsv(context, ref),
|
||||
tooltip: 'Import CSV',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: _pasteFromClipboard,
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
@@ -771,147 +885,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
}
|
||||
|
||||
class _QualityPickerOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _QualityPickerOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes
|
||||
class _TrackItemWithStatus extends ConsumerWidget {
|
||||
final Track track;
|
||||
@@ -941,6 +914,28 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Get thumbnail size from extension if track is from extension
|
||||
double thumbWidth = 56;
|
||||
double thumbHeight = 56;
|
||||
|
||||
// Get extension ID from track.source or from TrackState.searchExtensionId
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final extensionId = track.source ?? trackState.searchExtensionId;
|
||||
|
||||
if (extensionId != null && extensionId.isNotEmpty) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final extension = extState.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
if (extension?.searchBehavior != null) {
|
||||
final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56);
|
||||
thumbWidth = size.$1;
|
||||
thumbHeight = size.$2;
|
||||
// Debug: log only when using custom size
|
||||
if (thumbWidth != 56 || thumbHeight != 56) {
|
||||
debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
@@ -961,21 +956,21 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art
|
||||
// Album art with dynamic size based on extension config
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: track.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
memCacheWidth: (thumbWidth * 2).toInt(),
|
||||
memCacheHeight: (thumbHeight * 2).toInt(),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
@@ -1012,7 +1007,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
indent: thumbWidth + 24, // Adjust divider indent based on thumbnail width
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
|
||||
+66
-32
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
@@ -62,6 +63,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
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
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
@@ -118,6 +122,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void _onPageChanged(int index) {
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +175,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
||||
|
||||
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
@@ -182,6 +189,61 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
!trackState.isLoading &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
// Build tabs and destinations based on settings
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
QueueTab(
|
||||
parentPageController: _pageController,
|
||||
parentPageIndex: 1,
|
||||
nextPageIndex: showStore ? 2 : 3,
|
||||
),
|
||||
if (showStore) const StoreTab(),
|
||||
const SettingsTab(),
|
||||
];
|
||||
|
||||
final destinations = <NavigationDestination>[
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
),
|
||||
if (showStore)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.store_outlined),
|
||||
selectedIcon: Icon(Icons.store),
|
||||
label: 'Store',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
];
|
||||
|
||||
// Clamp current index if tabs changed
|
||||
final maxIndex = tabs.length - 1;
|
||||
if (_currentIndex > maxIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() => _currentIndex = maxIndex);
|
||||
_pageController.jumpToPage(maxIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
@@ -198,45 +260,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
],
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Playlist detail screen with Material Expressive 3 design
|
||||
class PlaylistScreen extends ConsumerWidget {
|
||||
@@ -168,10 +169,16 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -182,222 +189,20 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
if (tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
|
||||
+1548
-438
File diff suppressed because it is too large
Load Diff
@@ -13,45 +13,45 @@ class AboutPage extends StatelessWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'About',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'About',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// App header card with logo and description
|
||||
SliverToBoxAdapter(
|
||||
@@ -109,14 +109,14 @@ class AboutPage extends StatelessWidget {
|
||||
githubUsername: 'sachinsenal0x64',
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'DoubleDouble',
|
||||
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.music_note_outlined,
|
||||
title: 'DAB Music',
|
||||
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||
@@ -220,7 +220,7 @@ class AboutPage extends StatelessWidget {
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,30 +249,26 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// App logo
|
||||
// App logo
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Icon(
|
||||
Icons.music_note,
|
||||
size: 48,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
child: Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 88,
|
||||
height: 88,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -417,3 +413,80 @@ class _ContributorItem extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,80 +15,120 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: _AppBarTitle(
|
||||
title: 'Appearance',
|
||||
topPadding: topPadding,
|
||||
),
|
||||
),
|
||||
|
||||
// Preview Section
|
||||
SliverToBoxAdapter(
|
||||
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(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ThemeModeSelector(
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.brightness_2,
|
||||
title: 'AMOLED Dark',
|
||||
subtitle: 'Pure black background for OLED screens',
|
||||
value: themeSettings.useAmoled,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||
icon: Icons.wallpaper,
|
||||
title: 'Dynamic Color',
|
||||
subtitle: 'Use colors from your wallpaper',
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref
|
||||
.read(themeProvider.notifier)
|
||||
.setUseDynamicColor(value),
|
||||
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
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||
// Theme section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Theme'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.auto_awesome,
|
||||
title: 'Dynamic Color',
|
||||
subtitle: 'Use colors from your wallpaper',
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
showDivider: !themeSettings.useDynamicColor,
|
||||
_ThemeModeSelector(
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) =>
|
||||
ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
if (!themeSettings.useDynamicColor)
|
||||
_ColorPicker(
|
||||
currentColor: themeSettings.seedColorValue,
|
||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||
if (Theme.of(context).brightness == Brightness.dark)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.brightness_2,
|
||||
title: 'AMOLED Dark',
|
||||
subtitle: 'Pure black background',
|
||||
value: themeSettings.useAmoled,
|
||||
onChanged: (value) =>
|
||||
ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Layout section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Layout'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_HistoryViewSelector(
|
||||
currentMode: settings.historyViewMode,
|
||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
||||
onChanged: (mode) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setHistoryViewMode(mode),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 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
|
||||
class _AppBarTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final double topPadding;
|
||||
|
||||
|
||||
const _AppBarTitle({required this.title, required this.topPadding});
|
||||
|
||||
@override
|
||||
@@ -110,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
@@ -132,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
|
||||
class _ThemeModeSelector extends StatelessWidget {
|
||||
final ThemeMode currentMode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
|
||||
const _ThemeModeSelector({
|
||||
required this.currentMode,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(children: [
|
||||
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', 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)),
|
||||
]),
|
||||
child: Row(
|
||||
children: [
|
||||
_ThemeModeChip(
|
||||
icon: Icons.brightness_auto,
|
||||
label: 'System',
|
||||
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 bool isSelected;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
// Unselected chips need contrast with card background
|
||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.05),
|
||||
colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
@@ -185,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? 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)),
|
||||
]),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? 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 {
|
||||
final String currentMode;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
|
||||
const _HistoryViewSelector({
|
||||
required this.currentMode,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -254,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
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 bool isSelected;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
// Unselected chips need contrast with card background
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.05),
|
||||
colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
@@ -301,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? 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)),
|
||||
]),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,724 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
const ExtensionsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionsPage> createState() => _ExtensionsPageState();
|
||||
}
|
||||
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeExtensions();
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
// Create directories if they don't exist
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Extensions',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Loading indicator
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (extState.error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
extState.error!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Provider Priority
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Provider Priority'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Installed Extensions
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Installed Extensions'),
|
||||
),
|
||||
|
||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.extension_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No extensions installed',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Install .spotiflac-ext files to add new providers',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: extState.extensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < extState.extensions.length - 1,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ExtensionDetailPage(extensionId: ext.id),
|
||||
),
|
||||
),
|
||||
onToggle: (enabled) => ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setExtensionEnabled(ext.id, enabled),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
// Install button
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Install Extension'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Info section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Extensions can add new metadata and download providers. '
|
||||
'Only install extensions from trusted sources.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _installExtension() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.any,
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final file = result.files.first;
|
||||
if (file.path != null) {
|
||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select a .spotiflac-ext file'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.installExtension(file.path!);
|
||||
|
||||
if (mounted) {
|
||||
final extState = ref.read(extensionProvider);
|
||||
String message;
|
||||
if (success) {
|
||||
message = 'Extension installed successfully';
|
||||
} else {
|
||||
// Parse friendly error message
|
||||
message = _getFriendlyErrorMessage(extState.error);
|
||||
}
|
||||
|
||||
// Clear the error from state to avoid showing it twice (in error container)
|
||||
ref.read(extensionProvider.notifier).clearError();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse error message to be more user-friendly
|
||||
String _getFriendlyErrorMessage(String? error) {
|
||||
if (error == null) return 'Failed to install extension';
|
||||
|
||||
String message = error;
|
||||
|
||||
// Remove PlatformException wrapper if present
|
||||
// Format: PlatformException(ERROR, actual message, null, null)
|
||||
if (message.contains('PlatformException')) {
|
||||
// Try to extract the actual error message
|
||||
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
||||
if (match != null) {
|
||||
message = match.group(1)?.trim() ?? message;
|
||||
} else {
|
||||
// Fallback: try simpler extraction
|
||||
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
||||
if (simpleMatch != null) {
|
||||
message = simpleMatch.group(1)?.trim() ?? message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining artifacts
|
||||
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
||||
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionItem extends StatelessWidget {
|
||||
final Extension extension;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<bool> onToggle;
|
||||
|
||||
const _ExtensionItem({
|
||||
required this.extension,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasError = extension.status == 'error';
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Extension icon
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: hasError
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
File(extension.iconPath!),
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
hasError ? Icons.error_outline : Icons.extension,
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
hasError ? Icons.error_outline : Icons.extension,
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Extension info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasError
|
||||
? extension.errorMessage ?? 'Error loading extension'
|
||||
: 'v${extension.version} by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Toggle switch
|
||||
Switch(
|
||||
value: extension.enabled,
|
||||
onChanged: hasError ? null : onToggle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadPriorityItem extends ConsumerWidget {
|
||||
const _DownloadPriorityItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Check if any extension has download provider
|
||||
final hasDownloadExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasDownloadProvider);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasDownloadExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ProviderPriorityPage(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Download Priority',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasDownloadExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasDownloadExtensions
|
||||
? 'Set download service order'
|
||||
: 'No extensions with download provider',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataPriorityItem extends ConsumerWidget {
|
||||
const _MetadataPriorityItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Check if any extension has metadata provider
|
||||
final hasMetadataExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasMetadataExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const MetadataProviderPriorityPage(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Metadata Priority',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasMetadataExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasMetadataExtensions
|
||||
? 'Set search & metadata source order'
|
||||
: 'No extensions with metadata provider',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchProviderSelector extends ConsumerWidget {
|
||||
const _SearchProviderSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Get extensions with custom search
|
||||
final searchProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
// Get current provider name
|
||||
String currentProviderName = 'Default (Deezer/Spotify)';
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: searchProviders.isEmpty
|
||||
? null
|
||||
: () => _showSearchProviderPicker(context, ref, settings, searchProviders),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.manage_search,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Search Provider',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
searchProviders.isEmpty
|
||||
? 'No extensions with custom search'
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showSearchProviderPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
dynamic settings,
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Search Provider',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose which service to use for searching tracks',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Default option
|
||||
ListTile(
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: const Text('Default (Deezer/Spotify)'),
|
||||
subtitle: const Text('Use built-in search'),
|
||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
// Extension options
|
||||
...searchProviders.map((ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button - same as other settings pages
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const MetadataProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MetadataProviderPriorityPage> createState() => _MetadataProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderPriorityPage> {
|
||||
late List<String> _providers;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProviders();
|
||||
}
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
||||
|
||||
// Use saved priority if available, otherwise use default order
|
||||
if (extState.metadataProviderPriority.isNotEmpty) {
|
||||
_providers = List.from(extState.metadataProviderPriority);
|
||||
// Add any new providers not in saved priority
|
||||
for (final provider in allProviders) {
|
||||
if (!_providers.contains(provider)) {
|
||||
_providers.add(provider);
|
||||
}
|
||||
}
|
||||
// Remove providers that no longer exist
|
||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||
} else {
|
||||
_providers = allProviders;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
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: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
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(
|
||||
'Metadata Priority',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Description
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Drag to reorder metadata providers. The app will try providers '
|
||||
'from top to bottom when searching for tracks and fetching metadata.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Provider list
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _MetadataProviderItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Info section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Deezer has no rate limits and is recommended as primary. '
|
||||
'Spotify may rate limit after many requests.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard Changes?'),
|
||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Metadata provider priority saved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataProviderItem extends StatelessWidget {
|
||||
final String provider;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
|
||||
const _MetadataProviderItem({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(provider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Priority number
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Provider icon
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Provider name
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_MetadataProviderInfo _getProviderInfo(String provider) {
|
||||
switch (provider) {
|
||||
case 'deezer':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.album,
|
||||
description: 'No rate limits',
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'spotify':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Spotify',
|
||||
icon: Icons.music_note,
|
||||
description: 'May rate limit',
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
// Extension provider
|
||||
return _MetadataProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
description: 'Extension',
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final String description;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_MetadataProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.description,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
late List<String> _providers;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProviders();
|
||||
}
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
||||
|
||||
// Use saved priority if available, otherwise use default order
|
||||
if (extState.providerPriority.isNotEmpty) {
|
||||
// Start with saved priority
|
||||
_providers = List.from(extState.providerPriority);
|
||||
// Add any new providers not in saved priority
|
||||
for (final provider in allProviders) {
|
||||
if (!_providers.contains(provider)) {
|
||||
_providers.add(provider);
|
||||
}
|
||||
}
|
||||
// Remove providers that no longer exist
|
||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||
} else {
|
||||
_providers = allProviders;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
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: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
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(
|
||||
'Provider Priority',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Description
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Drag to reorder download providers. The app will try providers '
|
||||
'from top to bottom when downloading tracks.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Provider list
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _ProviderItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Info section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'If a track is not available on the first provider, '
|
||||
'the app will automatically try the next one.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard Changes?'),
|
||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setProviderPriority(_providers);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Provider priority saved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderItem extends StatelessWidget {
|
||||
final String provider;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
|
||||
const _ProviderItem({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
// Get provider info
|
||||
final info = _getProviderInfo(provider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Priority number
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Provider icon
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Provider name
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn ? 'Built-in' : 'Extension',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider) {
|
||||
switch (provider) {
|
||||
case 'tidal':
|
||||
return _ProviderInfo(
|
||||
name: 'Tidal',
|
||||
icon: Icons.music_note,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _ProviderInfo(
|
||||
name: 'Qobuz',
|
||||
icon: Icons.album,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'amazon':
|
||||
return _ProviderInfo(
|
||||
name: 'Amazon Music',
|
||||
icon: Icons.shopping_bag,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
// Extension provider
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_ProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||
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/log_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
@@ -13,29 +15,41 @@ class SettingsTab extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 130,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
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);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -48,7 +62,8 @@ class SettingsTab extends ConsumerWidget {
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Appearance',
|
||||
subtitle: 'Theme, colors, display',
|
||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||
onTap: () =>
|
||||
_navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.download_outlined,
|
||||
@@ -61,16 +76,28 @@ class SettingsTab extends ConsumerWidget {
|
||||
title: 'Options',
|
||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: 'Extensions',
|
||||
subtitle: 'Manage download providers',
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Second group: About
|
||||
// Second group: Logs & About
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.article_outlined,
|
||||
title: 'Logs',
|
||||
subtitle: 'View app logs for debugging',
|
||||
onTap: () => _navigateTo(context, const LogScreen()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
@@ -81,7 +108,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Fill remaining space
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
@@ -89,6 +116,27 @@ class SettingsTab extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
Navigator.of(context).push(
|
||||
// Use PageRouteBuilder for better predictive back gesture support
|
||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Use slide transition similar to MaterialPageRoute
|
||||
const begin = Offset(1.0, 0.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeInOut;
|
||||
var tween = Tween(begin: begin, end: end).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,24 +66,38 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
// Check storage permission
|
||||
PermissionStatus storageStatus;
|
||||
bool storageGranted = false;
|
||||
|
||||
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) {
|
||||
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 {
|
||||
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+)
|
||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||
if (_androidSdkVersion >= 33) {
|
||||
notificationStatus = await Permission.notification.status;
|
||||
debugPrint('[Permission] Notification=$notificationStatus');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storagePermissionGranted = storageStatus.isGranted;
|
||||
_storagePermissionGranted = storageGranted;
|
||||
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||
});
|
||||
}
|
||||
@@ -97,17 +111,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (Platform.isIOS) {
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
} else if (Platform.isAndroid) {
|
||||
PermissionStatus status;
|
||||
bool allGranted = false;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
// Android 13+: Use audio permission
|
||||
status = await Permission.audio.request();
|
||||
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
||||
|
||||
// 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) {
|
||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE
|
||||
// This opens system settings, not a dialog
|
||||
status = await Permission.manageExternalStorage.status;
|
||||
if (!status.isGranted) {
|
||||
// Show explanation dialog first
|
||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
||||
var manageStatus = await Permission.manageExternalStorage.status;
|
||||
if (!manageStatus.isGranted) {
|
||||
if (mounted) {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -131,23 +185,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
|
||||
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 {
|
||||
// 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);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog('Storage');
|
||||
} else {
|
||||
if (mounted) {
|
||||
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.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -380,11 +444,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
_clientIdController.text.trim(),
|
||||
_clientSecretController.text.trim(),
|
||||
);
|
||||
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
||||
// Set search source to Spotify when using custom credentials
|
||||
// Set search source to Spotify when credentials are provided
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||
} else {
|
||||
// Use Deezer as default search source
|
||||
// Use Deezer as default search source (free, no credentials required)
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class ExtensionDetailsScreen extends ConsumerStatefulWidget {
|
||||
final StoreExtension extension;
|
||||
|
||||
const ExtensionDetailsScreen({super.key, required this.extension});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionDetailsScreen> createState() =>
|
||||
_ExtensionDetailsScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionDetailsScreenState
|
||||
extends ConsumerState<ExtensionDetailsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
|
||||
final storeState = ref.watch(storeProvider);
|
||||
|
||||
// Find our extension in the store state to get the latest status
|
||||
// If not found in current store state (rare), fallback to widget.extension
|
||||
final liveExtension =
|
||||
storeState.extensions
|
||||
.where((e) => e.id == widget.extension.id)
|
||||
.firstOrNull ??
|
||||
widget.extension;
|
||||
|
||||
final isDownloading = storeState.downloadingId == liveExtension.id;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, liveExtension, colorScheme),
|
||||
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
'About',
|
||||
Icons.info_outline,
|
||||
colorScheme,
|
||||
),
|
||||
_buildDescription(context, liveExtension, colorScheme),
|
||||
|
||||
if (liveExtension.tags.isNotEmpty) ...[
|
||||
_buildSectionHeader(context, 'Tags', Icons.tag, colorScheme),
|
||||
_buildTags(context, liveExtension, colorScheme),
|
||||
],
|
||||
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
'Information',
|
||||
Icons.table_chart_outlined,
|
||||
colorScheme,
|
||||
),
|
||||
_buildMetadataTable(context, liveExtension, colorScheme),
|
||||
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
'Capabilities',
|
||||
Icons.extension_outlined,
|
||||
colorScheme,
|
||||
),
|
||||
_buildCapabilities(context, liveExtension, colorScheme),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: kToolbarHeight),
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: ext.iconUrl != null && ext.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
ext.iconUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildFallbackIcon(ext, colorScheme, 50),
|
||||
)
|
||||
: _buildFallbackIcon(ext, colorScheme, 50),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFallbackIcon(
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
double size,
|
||||
) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_getCategoryIcon(ext.category),
|
||||
size: size,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
bool isDownloading,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
ext.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'by ${ext.author}',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Badges row
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Badge(
|
||||
label: 'v${ext.version}',
|
||||
color: colorScheme.secondaryContainer,
|
||||
textColor: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
_Badge(
|
||||
label: _getCategoryName(ext.category),
|
||||
color: colorScheme.tertiaryContainer,
|
||||
textColor: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
if (ext.isInstalled)
|
||||
_Badge(
|
||||
label: 'Installed',
|
||||
color: colorScheme.primaryContainer,
|
||||
textColor: colorScheme.onPrimaryContainer,
|
||||
icon: Icons.check,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action Buttons
|
||||
if (isDownloading)
|
||||
Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (ext.hasUpdate)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _updateExtension(ext),
|
||||
icon: const Icon(Icons.update),
|
||||
label: Text('Update to v${ext.version}'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (ext.isInstalled)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Installed'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton.filled(
|
||||
onPressed: () => _uninstallExtension(ext),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
minimumSize: const Size(52, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
tooltip: 'Uninstall',
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: () => _installExtension(ext),
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Install Extension'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(
|
||||
BuildContext context,
|
||||
String title,
|
||||
IconData icon,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescription(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(
|
||||
ext.description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
height: 1.5,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTags(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: ext.tags
|
||||
.map(
|
||||
(tag) => Chip(
|
||||
label: Text(tag),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataTable(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_MetadataRow(
|
||||
label: 'Updated',
|
||||
value: ext.updatedAt.isNotEmpty
|
||||
? _formatDate(ext.updatedAt)
|
||||
: '-',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_MetadataRow(
|
||||
label: 'ID',
|
||||
value: ext.id,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_MetadataRow(
|
||||
label: 'Min App Version',
|
||||
value: ext.minAppVersion ?? 'Any',
|
||||
colorScheme: colorScheme,
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCapabilities(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
// Determine capabilities based on category
|
||||
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
|
||||
final isDownloadProvider = ext.category == 'download';
|
||||
final isLyricsProvider = ext.category == 'lyrics';
|
||||
final isUtility = ext.category == 'utility';
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_CapabilityRow(
|
||||
icon: Icons.search,
|
||||
label: 'Metadata Provider',
|
||||
enabled: isMetadataProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.download,
|
||||
label: 'Download Provider',
|
||||
enabled: isDownloadProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.lyrics,
|
||||
label: 'Lyrics Provider',
|
||||
enabled: isLyricsProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.build,
|
||||
label: 'Utility Functions',
|
||||
enabled: isUtility,
|
||||
colorScheme: colorScheme,
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
return 'Today';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays} days ago';
|
||||
} else if (diff.inDays < 30) {
|
||||
return '${(diff.inDays / 7).floor()} weeks ago';
|
||||
} else if (diff.inDays < 365) {
|
||||
return '${(diff.inDays / 30).floor()} months ago';
|
||||
} else {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
} catch (_) {
|
||||
return dateStr.split('T').first;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case 'metadata':
|
||||
return Icons.label_outline;
|
||||
case 'download':
|
||||
return Icons.download_outlined;
|
||||
case 'utility':
|
||||
return Icons.build_outlined;
|
||||
case 'lyrics':
|
||||
return Icons.lyrics_outlined;
|
||||
case 'integration':
|
||||
return Icons.link;
|
||||
default:
|
||||
return Icons.extension;
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryName(String category) {
|
||||
switch (category) {
|
||||
case 'metadata':
|
||||
return 'Metadata';
|
||||
case 'download':
|
||||
return 'Download';
|
||||
case 'utility':
|
||||
return 'Utility';
|
||||
case 'lyrics':
|
||||
return 'Lyrics';
|
||||
case 'integration':
|
||||
return 'Integration';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _installExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} installed.'
|
||||
: 'Failed to install ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.updateExtension(ext.id, tempDir.path);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} updated.'
|
||||
: 'Failed to update ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uninstallExtension(StoreExtension ext) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Uninstall Extension?'),
|
||||
content: Text('Are you sure you want to remove ${ext.displayName}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
'Uninstall',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
await ref.read(extensionProvider.notifier).removeExtension(ext.id);
|
||||
await ref.read(storeProvider.notifier).refresh();
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
final IconData? icon;
|
||||
|
||||
const _Badge({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.textColor,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isLast;
|
||||
|
||||
const _MetadataRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.colorScheme,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CapabilityRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isLast;
|
||||
|
||||
const _CapabilityRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.colorScheme,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: enabled ? colorScheme.primary : colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
enabled ? Icons.check_circle : Icons.cancel_outlined,
|
||||
size: 20,
|
||||
color: enabled ? colorScheme.primary : colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||
|
||||
class StoreTab extends ConsumerStatefulWidget {
|
||||
const StoreTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StoreTab> createState() => _StoreTabState();
|
||||
}
|
||||
|
||||
class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
final _searchController = TextEditingController();
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
|
||||
// Check if widget is still mounted after async operation
|
||||
if (!mounted) return;
|
||||
|
||||
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(storeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar - consistent with other tabs
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
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);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Store',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Search Bar
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search extensions...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref
|
||||
.read(storeProvider.notifier)
|
||||
.setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
||||
setState(() {}); // Update suffix icon
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Category Chips
|
||||
SliverToBoxAdapter(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_CategoryChip(
|
||||
label: 'All',
|
||||
icon: Icons.apps,
|
||||
isSelected: state.selectedCategory == null,
|
||||
onTap: () =>
|
||||
ref.read(storeProvider.notifier).setCategory(null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Metadata',
|
||||
icon: Icons.label_outline,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.metadata,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.metadata),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Download',
|
||||
icon: Icons.download_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.download,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.download),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Utility',
|
||||
icon: Icons.build_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.utility,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.utility),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Lyrics',
|
||||
icon: Icons.lyrics_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.lyrics,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.lyrics),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Integration',
|
||||
icon: Icons.link,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.integration),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
if (state.isLoading && state.extensions.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (state.error != null && state.extensions.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildErrorState(state.error!, colorScheme),
|
||||
)
|
||||
else if (state.filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
|
||||
else ...[
|
||||
// Extensions count
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Extensions list in grouped card (like queue_tab)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SettingsGroup(
|
||||
children: state.filteredExtensions.asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider:
|
||||
index < state.filteredExtensions.length - 1,
|
||||
isDownloading: state.downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
onTap: () => _showExtensionDetails(ext),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load store',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
|
||||
final hasFilters =
|
||||
state.searchQuery.isNotEmpty || state.selectedCategory != null;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
hasFilters ? Icons.search_off : Icons.extension_off,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasFilters ? 'No extensions found' : 'No extensions available',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (hasFilters) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref.read(storeProvider.notifier).clearSearch();
|
||||
},
|
||||
child: const Text('Clear filters'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showExtensionDetails(StoreExtension ext) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExtensionDetailsScreen(extension: ext),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _installExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} installed. Enable it in Settings > Extensions'
|
||||
: 'Failed to install ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.updateExtension(ext.id, tempDir.path);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} updated to v${ext.version}'
|
||||
: 'Failed to update ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryChip extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CategoryChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionItem extends StatelessWidget {
|
||||
final StoreExtension extension;
|
||||
final bool showDivider;
|
||||
final bool isDownloading;
|
||||
final VoidCallback onInstall;
|
||||
final VoidCallback onUpdate;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ExtensionItem({
|
||||
required this.extension,
|
||||
required this.showDivider,
|
||||
required this.isDownloading,
|
||||
required this.onInstall,
|
||||
required this.onUpdate,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case StoreCategory.metadata:
|
||||
return Icons.label_outline;
|
||||
case StoreCategory.download:
|
||||
return Icons.download_outlined;
|
||||
case StoreCategory.utility:
|
||||
return Icons.build_outlined;
|
||||
case StoreCategory.lyrics:
|
||||
return Icons.lyrics_outlined;
|
||||
case StoreCategory.integration:
|
||||
return Icons.link;
|
||||
default:
|
||||
return Icons.extension;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Extension icon - custom or category-based
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: extension.isInstalled
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child:
|
||||
extension.iconUrl != null && extension.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
extension.iconUrl!,
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value:
|
||||
loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Extension info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
// Version badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Action button
|
||||
if (isDownloading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else if (extension.hasUpdate)
|
||||
FilledButton.tonal(
|
||||
onPressed: onUpdate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: const Text('Update'),
|
||||
)
|
||||
else if (extension.isInstalled)
|
||||
OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Installed',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: onInstall,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: const Text('Install'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,22 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _checkFile() async {
|
||||
final file = File(widget.item.filePath);
|
||||
// Strip EXISTS: prefix from legacy history items
|
||||
var filePath = widget.item.filePath;
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
filePath = filePath.substring(7);
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
final exists = await file.exists();
|
||||
int? size;
|
||||
|
||||
if (exists) {
|
||||
try {
|
||||
size = await file.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_fileExists = exists;
|
||||
@@ -55,7 +63,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Use data directly from history item (cached from download)
|
||||
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;
|
||||
|
||||
// Clean filePath - strip EXISTS: prefix from legacy history items
|
||||
String get cleanFilePath {
|
||||
final path = item.filePath;
|
||||
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
||||
}
|
||||
int? get bitDepth => item.bitDepth;
|
||||
int? get sampleRate => item.sampleRate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -233,9 +258,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Track name
|
||||
// Track name (from file metadata)
|
||||
Text(
|
||||
item.trackName,
|
||||
trackName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
@@ -243,16 +268,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Artist name
|
||||
// Artist name (from file metadata)
|
||||
Text(
|
||||
item.artistName,
|
||||
artistName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Album name
|
||||
// Album name (from file metadata)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -263,7 +288,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.albumName,
|
||||
albumName,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -340,19 +365,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
// Metadata grid
|
||||
_buildMetadataGrid(context, colorScheme),
|
||||
|
||||
// Spotify link button
|
||||
// Streaming service link button
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openSpotifyUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('Open in Spotify'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => _openServiceUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -361,16 +391,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
||||
Future<void> _openServiceUrl(BuildContext context) async {
|
||||
if (item.spotifyId == null) return;
|
||||
|
||||
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
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 to open in Spotify app first using URI scheme
|
||||
// Try to open in App first using URI scheme
|
||||
final launched = await launchUrl(
|
||||
spotifyUri,
|
||||
appUri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
@@ -393,7 +431,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, webUrl);
|
||||
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 +439,43 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
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>[
|
||||
_MetadataItem('Track name', item.trackName),
|
||||
_MetadataItem('Artist', item.artistName),
|
||||
if (item.albumArtist != null && item.albumArtist != item.artistName)
|
||||
_MetadataItem('Album artist', item.albumArtist!),
|
||||
_MetadataItem('Album', item.albumName),
|
||||
if (item.trackNumber != null)
|
||||
_MetadataItem('Track number', item.trackNumber.toString()),
|
||||
if (item.discNumber != null && item.discNumber! > 1)
|
||||
_MetadataItem('Disc number', item.discNumber.toString()),
|
||||
_MetadataItem('Track name', trackName),
|
||||
_MetadataItem('Artist', artistName),
|
||||
if (albumArtist != null && albumArtist != artistName)
|
||||
_MetadataItem('Album artist', albumArtist!),
|
||||
_MetadataItem('Album', albumName),
|
||||
if (trackNumber != null && trackNumber! > 0)
|
||||
_MetadataItem('Track number', trackNumber.toString()),
|
||||
if (discNumber != null && discNumber! > 0)
|
||||
_MetadataItem('Disc number', discNumber.toString()),
|
||||
if (item.duration != null)
|
||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||
if (item.quality != null && item.quality!.contains('bit'))
|
||||
_MetadataItem('Audio quality', item.quality!),
|
||||
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
||||
_MetadataItem('Release date', item.releaseDate!),
|
||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
||||
_MetadataItem('ISRC', item.isrc!),
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
_MetadataItem('Spotify ID', item.spotifyId!),
|
||||
if (item.quality != null && item.quality!.isNotEmpty)
|
||||
_MetadataItem('Quality', _formatQuality(item.quality!)),
|
||||
if (audioQualityStr != null)
|
||||
_MetadataItem('Audio quality', audioQualityStr),
|
||||
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||
_MetadataItem('Release date', releaseDate!),
|
||||
if (isrc != null && isrc!.isNotEmpty)
|
||||
_MetadataItem('ISRC', isrc!),
|
||||
];
|
||||
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||
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('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||
];
|
||||
]);
|
||||
|
||||
return Column(
|
||||
children: items.map((metadata) {
|
||||
@@ -476,34 +526,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
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) {
|
||||
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
||||
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
|
||||
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
||||
|
||||
return Card(
|
||||
@@ -570,7 +594,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.quality != null)
|
||||
if (bitDepth != null && sampleRate != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
@@ -578,7 +602,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_formatQualityShort(item.quality!),
|
||||
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -619,7 +643,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// File path
|
||||
InkWell(
|
||||
onTap: () => _copyToClipboard(context, item.filePath),
|
||||
onTap: () => _copyToClipboard(context, cleanFilePath),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -631,7 +655,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.filePath,
|
||||
cleanFilePath,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -764,7 +788,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
||||
).timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => '', // Return empty string on timeout
|
||||
@@ -821,7 +845,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton.icon(
|
||||
onPressed: fileExists ? () => _openFile(context, item.filePath) : null,
|
||||
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -878,7 +902,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
title: const Text('Copy file path'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_copyToClipboard(context, item.filePath);
|
||||
_copyToClipboard(context, cleanFilePath);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -891,7 +915,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
ListTile(
|
||||
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: () {
|
||||
Navigator.pop(context);
|
||||
_confirmDelete(context, ref, colorScheme);
|
||||
@@ -908,10 +932,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove from history?'),
|
||||
title: const Text('Remove from device?'),
|
||||
content: const Text(
|
||||
'This will remove the track from your download history. '
|
||||
'The downloaded file will not be deleted.',
|
||||
'This will permanently delete the downloaded file and remove it from your history.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -919,12 +942,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
// Delete the file first
|
||||
try {
|
||||
final file = File(cleanFilePath);
|
||||
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);
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -959,7 +996,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _shareFile(BuildContext context) async {
|
||||
final file = File(item.filePath);
|
||||
final file = File(cleanFilePath);
|
||||
if (!await file.exists()) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -971,7 +1008,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(item.filePath)],
|
||||
files: [XFile(cleanFilePath)],
|
||||
text: '${item.trackName} - ${item.artistName}',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:convert';
|
||||
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
|
||||
class PlatformBridge {
|
||||
@@ -7,18 +10,21 @@ class PlatformBridge {
|
||||
|
||||
/// Parse and validate Spotify URL
|
||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||
_log.d('parseSpotifyUrl: $url');
|
||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get Spotify metadata from URL
|
||||
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
||||
_log.d('getSpotifyMetadata: $url');
|
||||
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Search Spotify
|
||||
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
||||
_log.d('searchSpotify: "$query" (limit: $limit)');
|
||||
final result = await _channel.invokeMethod('searchSpotify', {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
@@ -28,6 +34,7 @@ class PlatformBridge {
|
||||
|
||||
/// Search Spotify for tracks and artists
|
||||
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', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
@@ -38,6 +45,7 @@ class PlatformBridge {
|
||||
|
||||
/// Check track availability on streaming services
|
||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
'spotify_id': spotifyId,
|
||||
'isrc': isrc,
|
||||
@@ -67,6 +75,7 @@ class PlatformBridge {
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
}) async {
|
||||
_log.i('downloadTrack: "$trackName" by $artistName via $service');
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
'service': service,
|
||||
@@ -90,7 +99,13 @@ class PlatformBridge {
|
||||
});
|
||||
|
||||
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
|
||||
@@ -111,10 +126,11 @@ class PlatformBridge {
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
String? releaseDate,
|
||||
String preferredService = 'qobuz',
|
||||
String preferredService = 'tidal',
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
}) async {
|
||||
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
'service': preferredService,
|
||||
@@ -138,7 +154,22 @@ class PlatformBridge {
|
||||
});
|
||||
|
||||
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)
|
||||
@@ -248,6 +279,16 @@ class PlatformBridge {
|
||||
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
|
||||
static Future<void> startDownloadService({
|
||||
String trackName = '',
|
||||
@@ -290,7 +331,6 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
/// Set custom Spotify API credentials
|
||||
/// Pass empty strings to use default credentials
|
||||
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||
'client_id': clientId,
|
||||
@@ -298,6 +338,13 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if Spotify credentials are configured
|
||||
/// Returns true if credentials are available (custom or env vars)
|
||||
static Future<bool> hasSpotifyCredentials() async {
|
||||
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Pre-warm track ID cache for album/playlist tracks
|
||||
/// This runs in background and returns immediately
|
||||
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||
@@ -335,6 +382,9 @@ class PlatformBridge {
|
||||
'resource_type': resourceType,
|
||||
'resource_id': resourceId,
|
||||
});
|
||||
if (result == null) {
|
||||
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
|
||||
}
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@@ -364,4 +414,449 @@ class PlatformBridge {
|
||||
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||
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});
|
||||
}
|
||||
|
||||
// ==================== EXTENSION SYSTEM ====================
|
||||
|
||||
/// Initialize the extension system
|
||||
static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async {
|
||||
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
||||
await _channel.invokeMethod('initExtensionSystem', {
|
||||
'extensions_dir': extensionsDir,
|
||||
'data_dir': dataDir,
|
||||
});
|
||||
}
|
||||
|
||||
/// Load all extensions from directory
|
||||
static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async {
|
||||
_log.d('loadExtensionsFromDir: $dirPath');
|
||||
final result = await _channel.invokeMethod('loadExtensionsFromDir', {
|
||||
'dir_path': dirPath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Load a single extension from file
|
||||
static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async {
|
||||
_log.d('loadExtensionFromPath: $filePath');
|
||||
final result = await _channel.invokeMethod('loadExtensionFromPath', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Unload an extension
|
||||
static Future<void> unloadExtension(String extensionId) async {
|
||||
_log.d('unloadExtension: $extensionId');
|
||||
await _channel.invokeMethod('unloadExtension', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove an extension completely (unload + delete files)
|
||||
static Future<void> removeExtension(String extensionId) async {
|
||||
_log.d('removeExtension: $extensionId');
|
||||
await _channel.invokeMethod('removeExtension', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Upgrade an existing extension from a new package file
|
||||
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
|
||||
_log.d('upgradeExtension: $filePath');
|
||||
final result = await _channel.invokeMethod('upgradeExtension', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Check if a package file is an upgrade for an existing extension
|
||||
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
_log.d('checkExtensionUpgrade: $filePath');
|
||||
final result = await _channel.invokeMethod('checkExtensionUpgrade', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get all installed extensions
|
||||
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
|
||||
final result = await _channel.invokeMethod('getInstalledExtensions');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Enable or disable an extension
|
||||
static Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
_log.d('setExtensionEnabled: $extensionId = $enabled');
|
||||
await _channel.invokeMethod('setExtensionEnabled', {
|
||||
'extension_id': extensionId,
|
||||
'enabled': enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set provider priority order
|
||||
static Future<void> setProviderPriority(List<String> providerIds) async {
|
||||
_log.d('setProviderPriority: $providerIds');
|
||||
await _channel.invokeMethod('setProviderPriority', {
|
||||
'priority': jsonEncode(providerIds),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get provider priority order
|
||||
static Future<List<String>> getProviderPriority() async {
|
||||
final result = await _channel.invokeMethod('getProviderPriority');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
}
|
||||
|
||||
/// Set metadata provider priority order
|
||||
static Future<void> setMetadataProviderPriority(List<String> providerIds) async {
|
||||
_log.d('setMetadataProviderPriority: $providerIds');
|
||||
await _channel.invokeMethod('setMetadataProviderPriority', {
|
||||
'priority': jsonEncode(providerIds),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get metadata provider priority order
|
||||
static Future<List<String>> getMetadataProviderPriority() async {
|
||||
final result = await _channel.invokeMethod('getMetadataProviderPriority');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
}
|
||||
|
||||
/// Get extension settings
|
||||
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('getExtensionSettings', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set extension settings
|
||||
static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
_log.d('setExtensionSettings: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionSettings', {
|
||||
'extension_id': extensionId,
|
||||
'settings': jsonEncode(settings),
|
||||
});
|
||||
}
|
||||
|
||||
/// Search tracks using extension providers
|
||||
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
|
||||
_log.d('searchTracksWithExtensions: "$query"');
|
||||
final result = await _channel.invokeMethod('searchTracksWithExtensions', {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Download with extension providers (includes fallback)
|
||||
static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
required String isrc,
|
||||
required String spotifyId,
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String albumName,
|
||||
String? albumArtist,
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
String? releaseDate,
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
String? source, // Extension ID that provided this track (prioritize this extension)
|
||||
}) async {
|
||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
'spotify_id': spotifyId,
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'album_name': albumName,
|
||||
'album_artist': albumArtist ?? artistName,
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'source': source ?? '', // Extension ID that provided this track
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Cleanup all extensions (call on app close)
|
||||
static Future<void> cleanupExtensions() async {
|
||||
_log.d('cleanupExtensions');
|
||||
await _channel.invokeMethod('cleanupExtensions');
|
||||
}
|
||||
|
||||
// ==================== EXTENSION AUTH API ====================
|
||||
|
||||
/// Get pending auth request for an extension (if any)
|
||||
static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('getExtensionPendingAuth', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
if (result == null) return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set auth code for an extension (after OAuth callback)
|
||||
static Future<void> setExtensionAuthCode(String extensionId, String authCode) async {
|
||||
_log.d('setExtensionAuthCode: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionAuthCode', {
|
||||
'extension_id': extensionId,
|
||||
'auth_code': authCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set tokens for an extension (after token exchange)
|
||||
static Future<void> setExtensionTokens(
|
||||
String extensionId, {
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
int? expiresIn,
|
||||
}) async {
|
||||
_log.d('setExtensionTokens: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionTokens', {
|
||||
'extension_id': extensionId,
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken ?? '',
|
||||
'expires_in': expiresIn ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear pending auth request for an extension
|
||||
static Future<void> clearExtensionPendingAuth(String extensionId) async {
|
||||
await _channel.invokeMethod('clearExtensionPendingAuth', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if extension is authenticated
|
||||
static Future<bool> isExtensionAuthenticated(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('isExtensionAuthenticated', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Get all pending auth requests (for polling)
|
||||
static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async {
|
||||
final result = await _channel.invokeMethod('getAllPendingAuthRequests');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION FFMPEG API ====================
|
||||
|
||||
/// Get pending FFmpeg command for execution
|
||||
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async {
|
||||
final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
|
||||
'command_id': commandId,
|
||||
});
|
||||
if (result == null) return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set FFmpeg command result
|
||||
static Future<void> setFFmpegCommandResult(
|
||||
String commandId, {
|
||||
required bool success,
|
||||
String output = '',
|
||||
String error = '',
|
||||
}) async {
|
||||
await _channel.invokeMethod('setFFmpegCommandResult', {
|
||||
'command_id': commandId,
|
||||
'success': success,
|
||||
'output': output,
|
||||
'error': error,
|
||||
});
|
||||
}
|
||||
|
||||
/// Get all pending FFmpeg commands
|
||||
static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async {
|
||||
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||
|
||||
/// Perform custom search using an extension
|
||||
static Future<List<Map<String, dynamic>>> customSearchWithExtension(
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('customSearchWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'query': query,
|
||||
'options': options != null ? jsonEncode(options) : '',
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get all extensions that provide custom search
|
||||
static Future<List<Map<String, dynamic>>> getSearchProviders() async {
|
||||
final result = await _channel.invokeMethod('getSearchProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION URL HANDLER ====================
|
||||
|
||||
/// Handle a URL with any matching extension
|
||||
/// Returns null if no extension can handle the URL
|
||||
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('handleURLWithExtension', {
|
||||
'url': url,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
// No extension found or error handling URL
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an extension that can handle the given URL
|
||||
/// Returns extension ID or null if none found
|
||||
static Future<String?> findURLHandler(String url) async {
|
||||
final result = await _channel.invokeMethod('findURLHandler', {
|
||||
'url': url,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Get all extensions that handle custom URLs
|
||||
static Future<List<Map<String, dynamic>>> getURLHandlers() async {
|
||||
final result = await _channel.invokeMethod('getURLHandlers');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
/// Run post-processing hooks on a file
|
||||
static Future<Map<String, dynamic>> runPostProcessing(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('runPostProcessing', {
|
||||
'file_path': filePath,
|
||||
'metadata': metadata != null ? jsonEncode(metadata) : '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get all extensions that provide post-processing
|
||||
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
|
||||
final result = await _channel.invokeMethod('getPostProcessingProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION STORE ====================
|
||||
|
||||
/// Initialize extension store
|
||||
static Future<void> initExtensionStore(String cacheDir) async {
|
||||
_log.d('initExtensionStore: $cacheDir');
|
||||
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
|
||||
}
|
||||
|
||||
/// Get all extensions from store with installation status
|
||||
static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async {
|
||||
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
|
||||
final result = await _channel.invokeMethod('getStoreExtensions', {
|
||||
'force_refresh': forceRefresh,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Search extensions in store
|
||||
static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async {
|
||||
_log.d('searchStoreExtensions: "$query" (category: $category)');
|
||||
final result = await _channel.invokeMethod('searchStoreExtensions', {
|
||||
'query': query,
|
||||
'category': category ?? '',
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get store categories
|
||||
static Future<List<String>> getStoreCategories() async {
|
||||
final result = await _channel.invokeMethod('getStoreCategories');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.cast<String>();
|
||||
}
|
||||
|
||||
/// Download extension from store
|
||||
static Future<String> downloadStoreExtension(String extensionId, String destDir) async {
|
||||
_log.i('downloadStoreExtension: $extensionId to $destDir');
|
||||
final result = await _channel.invokeMethod('downloadStoreExtension', {
|
||||
'extension_id': extensionId,
|
||||
'dest_dir': destDir,
|
||||
});
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Clear store cache
|
||||
static Future<void> clearStoreCache() async {
|
||||
_log.d('clearStoreCache');
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
}
|
||||
}
|
||||
|
||||
+105
-78
@@ -7,11 +7,9 @@ class AppTheme {
|
||||
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
||||
|
||||
/// Create light theme
|
||||
static ThemeData light({
|
||||
ColorScheme? dynamicScheme,
|
||||
Color? seedColor,
|
||||
}) {
|
||||
final scheme = dynamicScheme ??
|
||||
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
|
||||
final scheme =
|
||||
dynamicScheme ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: seedColor ?? defaultSeedColor,
|
||||
brightness: Brightness.light,
|
||||
@@ -45,7 +43,8 @@ class AppTheme {
|
||||
Color? seedColor,
|
||||
bool isAmoled = false,
|
||||
}) {
|
||||
final scheme = dynamicScheme ??
|
||||
final scheme =
|
||||
dynamicScheme ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: seedColor ?? defaultSeedColor,
|
||||
brightness: Brightness.dark,
|
||||
@@ -75,34 +74,41 @@ class AppTheme {
|
||||
}
|
||||
|
||||
/// AppBar theme
|
||||
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: isAmoled ? 0 : 3,
|
||||
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
color: scheme.onSurface,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
static AppBarTheme _appBarTheme(
|
||||
ColorScheme scheme, {
|
||||
bool isAmoled = false,
|
||||
}) => AppBarTheme(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: isAmoled ? 0 : 3,
|
||||
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
color: scheme.onSurface,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
|
||||
/// Card theme
|
||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
color: scheme.surfaceContainerLow,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 12 -> 16
|
||||
color: scheme.surfaceContainerLow,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
|
||||
/// Elevated button theme
|
||||
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
||||
ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
@@ -111,7 +117,9 @@ class AppTheme {
|
||||
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
||||
FilledButtonThemeData(
|
||||
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),
|
||||
),
|
||||
);
|
||||
@@ -120,7 +128,9 @@ class AppTheme {
|
||||
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
||||
OutlinedButtonThemeData(
|
||||
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),
|
||||
),
|
||||
);
|
||||
@@ -129,7 +139,9 @@ class AppTheme {
|
||||
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
||||
TextButtonThemeData(
|
||||
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),
|
||||
),
|
||||
);
|
||||
@@ -147,52 +159,63 @@ class AppTheme {
|
||||
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
||||
InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerHighest,
|
||||
fillColor: scheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
), // Added transparency
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
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
|
||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||
ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 12 -> 16
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
);
|
||||
|
||||
/// Dialog theme
|
||||
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
||||
elevation: 6,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
backgroundColor: scheme.surfaceContainerHigh,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
elevation: 6,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
backgroundColor: scheme.surfaceContainerHigh,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
|
||||
/// Navigation bar theme
|
||||
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
|
||||
NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
||||
indicatorColor: scheme.secondaryContainer,
|
||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
);
|
||||
static NavigationBarThemeData _navigationBarTheme(
|
||||
ColorScheme scheme, {
|
||||
bool isAmoled = false,
|
||||
}) => NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
||||
indicatorColor: scheme.secondaryContainer,
|
||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
);
|
||||
|
||||
/// SnackBar theme
|
||||
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData(
|
||||
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
|
||||
SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
backgroundColor: scheme.inverseSurface,
|
||||
@@ -200,40 +223,44 @@ class AppTheme {
|
||||
);
|
||||
|
||||
/// Progress indicator theme
|
||||
static ProgressIndicatorThemeData _progressIndicatorTheme(ColorScheme scheme) =>
|
||||
ProgressIndicatorThemeData(
|
||||
color: scheme.primary,
|
||||
linearTrackColor: scheme.surfaceContainerHighest,
|
||||
circularTrackColor: scheme.surfaceContainerHighest,
|
||||
);
|
||||
static ProgressIndicatorThemeData _progressIndicatorTheme(
|
||||
ColorScheme scheme,
|
||||
) => ProgressIndicatorThemeData(
|
||||
color: scheme.primary,
|
||||
linearTrackColor: scheme.surfaceContainerHighest,
|
||||
circularTrackColor: scheme.surfaceContainerHighest,
|
||||
);
|
||||
|
||||
/// Switch theme
|
||||
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return scheme.onPrimary;
|
||||
}
|
||||
return scheme.outline;
|
||||
}),
|
||||
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return scheme.primary;
|
||||
}
|
||||
return scheme.surfaceContainerHighest;
|
||||
}),
|
||||
);
|
||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return scheme.onPrimary;
|
||||
}
|
||||
return scheme.outline;
|
||||
}),
|
||||
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return scheme.primary;
|
||||
}
|
||||
return scheme.surfaceContainerHighest;
|
||||
}),
|
||||
thumbIcon: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return Icon(Icons.check, color: scheme.primary);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
/// Chip theme
|
||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
selectedColor: scheme.secondaryContainer,
|
||||
);
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
selectedColor: scheme.secondaryContainer,
|
||||
);
|
||||
|
||||
/// Divider theme
|
||||
static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(
|
||||
color: scheme.outlineVariant,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
);
|
||||
static DividerThemeData _dividerTheme(ColorScheme scheme) =>
|
||||
DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
|
||||
}
|
||||
|
||||
+298
-9
@@ -1,7 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/foundation.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
|
||||
/// Uses pretty printer in debug mode for readable output
|
||||
final log = Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
@@ -15,14 +242,76 @@ final log = Logger(
|
||||
);
|
||||
|
||||
/// 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 {
|
||||
final String _tag;
|
||||
|
||||
AppLogger(this._tag);
|
||||
|
||||
void d(String message) => log.d('[$_tag] $message');
|
||||
void i(String message) => log.i('[$_tag] $message');
|
||||
void w(String message) => log.w('[$_tag] $message');
|
||||
void e(String message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
|
||||
late final Logger? _logger;
|
||||
|
||||
AppLogger(this._tag) {
|
||||
// Only create Logger instance in debug mode
|
||||
// In release mode, we write directly to LogBuffer
|
||||
if (kDebugMode) {
|
||||
_logger = Logger(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
/// Built-in service info with quality options
|
||||
class BuiltInService {
|
||||
final String id;
|
||||
final String label;
|
||||
final List<QualityOption> qualityOptions;
|
||||
|
||||
const BuiltInService({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.qualityOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for built-in services (Tidal, Qobuz, Amazon)
|
||||
const _builtInServices = [
|
||||
BuiltInService(
|
||||
id: 'tidal',
|
||||
label: 'Tidal',
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'qobuz',
|
||||
label: 'Qobuz',
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'amazon',
|
||||
label: 'Amazon',
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// A reusable widget for selecting download service (built-in + extensions)
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final void Function(String quality, String service) onSelect;
|
||||
|
||||
const DownloadServicePicker({
|
||||
super.key,
|
||||
this.trackName,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadServicePicker> createState() => _DownloadServicePickerState();
|
||||
|
||||
/// Show the download service picker as a modal bottom sheet
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
required void Function(String quality, String service) onSelect,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => DownloadServicePicker(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
onSelect: onSelect,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
late String _selectedService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedService = ref.read(settingsProvider).defaultService;
|
||||
}
|
||||
|
||||
/// Get quality options for the selected service
|
||||
List<QualityOption> _getQualityOptions() {
|
||||
// Check if it's a built-in service
|
||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||
if (builtIn != null) {
|
||||
return builtIn.qualityOptions;
|
||||
}
|
||||
|
||||
// Check if it's an extension
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||
return ext.qualityOptions;
|
||||
}
|
||||
|
||||
// Default quality options if extension doesn't specify any
|
||||
return const [
|
||||
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
|
||||
// Get enabled download provider extensions
|
||||
final downloadExtensions = extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final qualityOptions = _getQualityOptions();
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Track info header (if provided)
|
||||
if (widget.trackName != null) ...[
|
||||
_TrackInfoHeader(
|
||||
trackName: widget.trackName!,
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Service selector section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
'Download From',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// Built-in services
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Built-in services
|
||||
for (final service in _builtInServices)
|
||||
_ServiceChip(
|
||||
label: service.label,
|
||||
isSelected: _selectedService == service.id,
|
||||
onTap: () => setState(() => _selectedService = service.id),
|
||||
),
|
||||
// Extension services
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
label: ext.displayName,
|
||||
isSelected: _selectedService == ext.id,
|
||||
onTap: () => setState(() => _selectedService = ext.id),
|
||||
iconPath: ext.iconPath,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Quality selector section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
'Select Quality',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// Disclaimer for built-in services
|
||||
if (_builtInServices.any((s) => s.id == _selectedService))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Quality options
|
||||
for (final quality in qualityOptions)
|
||||
_QualityOption(
|
||||
title: quality.label,
|
||||
subtitle: quality.description ?? '',
|
||||
icon: _getQualityIcon(quality.id),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getQualityIcon(String qualityId) {
|
||||
switch (qualityId.toUpperCase()) {
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return Icons.four_k;
|
||||
case 'HI_RES':
|
||||
return Icons.high_quality;
|
||||
case 'LOSSLESS':
|
||||
return Icons.music_note;
|
||||
case 'MP3_320':
|
||||
case 'MP3':
|
||||
return Icons.audiotrack;
|
||||
case 'OPUS':
|
||||
case 'OPUS_128':
|
||||
return Icons.graphic_eq;
|
||||
default:
|
||||
return Icons.music_note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: subtitle.isNotEmpty
|
||||
? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant))
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final String? iconPath;
|
||||
|
||||
const _ServiceChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.iconPath,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (iconPath != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.file(
|
||||
File(iconPath!),
|
||||
width: 18,
|
||||
height: 18,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.extension,
|
||||
size: 18,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const _TrackInfoHeader({
|
||||
required this.trackName,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(
|
||||
widget.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(
|
||||
text: titleSpan,
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class SettingsItem extends StatelessWidget {
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
@@ -133,6 +133,7 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final bool showDivider;
|
||||
final bool enabled;
|
||||
|
||||
const SettingsSwitchItem({
|
||||
super.key,
|
||||
@@ -142,53 +143,60 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
required this.value,
|
||||
this.onChanged,
|
||||
this.showDivider = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDisabled = !enabled || onChanged == null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onChanged != null ? () => onChanged!(!value) : null,
|
||||
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: [
|
||||
if (icon != null) ...[
|
||||
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),
|
||||
Opacity(
|
||||
opacity: isDisabled ? 0.5 : 1.0,
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : () => onChanged!(!value),
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : null,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: isDisabled ? null : onChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 2.1.7+45
|
||||
version: 3.0.0+57
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 2.1.7+45
|
||||
version: 3.0.0+57
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user