Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d98960d053 | |||
| d417743654 | |||
| efbf5d4c5b | |||
| c673581c32 | |||
| a6d488696b | |||
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 | |||
| 11e7034cec | |||
| f12c18d76b | |||
| 0da39a1b8b | |||
| f29fe5054c | |||
| c8c0164964 | |||
| 52dd657913 | |||
| c30f9fe412 | |||
| bea5dd1d4a | |||
| 8726a0858a | |||
| 74bc747599 | |||
| cbc8fdcb0c | |||
| 3b79b4f1ca | |||
| 5692a76650 | |||
| 7a009ad0af | |||
| e5e75e7092 | |||
| 01b8fd2480 | |||
| ee807a44cc | |||
| c9b905eb18 |
@@ -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 (Alpha)
|
||||||
|
description: Request new API features or capabilities for extension development (Extension system is in alpha)
|
||||||
|
title: "[Extension API]: "
|
||||||
|
labels: ["enhancement", "extension-api"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for helping improve the SpotiFLAC Extension API!
|
||||||
|
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have read the [Extension Development Guide](https://zarz.moe/docs)
|
||||||
|
required: true
|
||||||
|
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extension_goal
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to build?
|
||||||
|
description: Describe the extension or feature you're developing
|
||||||
|
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current_limitation
|
||||||
|
attributes:
|
||||||
|
label: Current API Limitation
|
||||||
|
description: What's missing or limiting in the current extension API?
|
||||||
|
placeholder: |
|
||||||
|
The current API doesn't support:
|
||||||
|
- [missing feature 1]
|
||||||
|
- [missing feature 2]
|
||||||
|
|
||||||
|
This prevents me from...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed_api
|
||||||
|
attributes:
|
||||||
|
label: Proposed API / Feature
|
||||||
|
description: Describe the API or feature you'd like to see added
|
||||||
|
placeholder: |
|
||||||
|
I would like to have:
|
||||||
|
- A new function `api.newFeature()` that does X
|
||||||
|
- A new manifest field `newOption` that enables Y
|
||||||
|
- Access to Z capability...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use_case
|
||||||
|
attributes:
|
||||||
|
label: Use Case Example
|
||||||
|
description: Provide a code example of how you would use this feature
|
||||||
|
placeholder: |
|
||||||
|
```javascript
|
||||||
|
// Example usage in extension code
|
||||||
|
function download(request, progressCallback) {
|
||||||
|
const result = api.proposedFeature(params);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: api_category
|
||||||
|
attributes:
|
||||||
|
label: API Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- HTTP/Network API
|
||||||
|
- File System API
|
||||||
|
- Storage API
|
||||||
|
- FFmpeg/Audio Processing
|
||||||
|
- Manifest Options
|
||||||
|
- Runtime Functions
|
||||||
|
- UI Integration
|
||||||
|
- Authentication
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: How critical is this for your extension?
|
||||||
|
options:
|
||||||
|
- Blocker - Cannot build my extension without this
|
||||||
|
- High - Major functionality depends on this
|
||||||
|
- Medium - Would significantly improve my extension
|
||||||
|
- Low - Nice to have
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: workaround
|
||||||
|
attributes:
|
||||||
|
label: Current Workaround
|
||||||
|
description: Are you using any workaround currently? If so, describe it.
|
||||||
|
placeholder: "Currently I'm working around this by..."
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||||
|
placeholder: "Similar feature in other platforms: ..."
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for suggesting a feature! Please fill out the form below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this feature hasn't been requested yet
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem / Motivation
|
||||||
|
description: Is your feature request related to a problem? Please describe.
|
||||||
|
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: Describe the solution you'd like
|
||||||
|
placeholder: A clear description of what you want to happen...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Describe any alternative solutions or features you've considered
|
||||||
|
placeholder: Other approaches you've thought about...
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- UI/UX Improvement
|
||||||
|
- Download Feature
|
||||||
|
- New Service Integration
|
||||||
|
- Metadata/Tagging
|
||||||
|
- Performance
|
||||||
|
- Settings/Configuration
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, mockups, or screenshots about the feature request
|
||||||
|
placeholder: Add any other context or screenshots...
|
||||||
@@ -3,13 +3,13 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Version tag (e.g., v1.0.0)'
|
description: "Version tag (e.g., v1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
default: 'v1.0.0'
|
default: "v1.0.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Get version first (quick job)
|
# Get version first (quick job)
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
build-android:
|
build-android:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: get-version
|
needs: get-version
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
run: |
|
run: |
|
||||||
@@ -65,13 +65,13 @@ jobs:
|
|||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: "temurin"
|
||||||
java-version: '17'
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -89,15 +89,16 @@ jobs:
|
|||||||
# Use pre-installed Android SDK on GitHub runners
|
# Use pre-installed Android SDK on GitHub runners
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
|
|
||||||
# Accept licenses
|
# Accept licenses
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
# Install NDK (required for gomobile)
|
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
|
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||||
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -115,7 +116,7 @@ jobs:
|
|||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
@@ -125,7 +126,14 @@ jobs:
|
|||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build APK (Release - unsigned)
|
- name: Build APK (Release - unsigned)
|
||||||
run: flutter build apk --release --split-per-abi
|
run: |
|
||||||
|
flutter build apk --release --split-per-abi || true
|
||||||
|
# Verify APKs were created
|
||||||
|
ls -la build/app/outputs/flutter-apk/
|
||||||
|
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||||
|
echo "ERROR: APK not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Sign APKs
|
- name: Sign APKs
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
@@ -137,7 +145,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "34.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
@@ -157,8 +165,8 @@ jobs:
|
|||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -166,7 +174,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -194,51 +202,51 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ls -la ios/Frameworks/
|
ls -la ios/Frameworks/
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||||
|
|
||||||
- name: Add XCFramework to Xcode project
|
- name: Add XCFramework to Xcode project
|
||||||
run: |
|
run: |
|
||||||
# Install xcodeproj gem for modifying Xcode project
|
# Install xcodeproj gem for modifying Xcode project
|
||||||
sudo gem install xcodeproj
|
sudo gem install xcodeproj
|
||||||
|
|
||||||
# Create Ruby script to add framework
|
# Create Ruby script to add framework
|
||||||
cat > add_framework.rb << 'EOF'
|
cat > add_framework.rb << 'EOF'
|
||||||
require 'xcodeproj'
|
require 'xcodeproj'
|
||||||
|
|
||||||
project_path = 'ios/Runner.xcodeproj'
|
project_path = 'ios/Runner.xcodeproj'
|
||||||
project = Xcodeproj::Project.open(project_path)
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
|
||||||
# Get the main target
|
# Get the main target
|
||||||
target = project.targets.find { |t| t.name == 'Runner' }
|
target = project.targets.find { |t| t.name == 'Runner' }
|
||||||
|
|
||||||
# Get or create Frameworks group
|
# Get or create Frameworks group
|
||||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||||
|
|
||||||
# Add XCFramework reference
|
# Add XCFramework reference
|
||||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||||
|
|
||||||
# Add to frameworks build phase
|
# Add to frameworks build phase
|
||||||
frameworks_build_phase = target.frameworks_build_phase
|
frameworks_build_phase = target.frameworks_build_phase
|
||||||
frameworks_build_phase.add_file_reference(framework_ref)
|
frameworks_build_phase.add_file_reference(framework_ref)
|
||||||
|
|
||||||
# Add to embed frameworks build phase
|
# Add to embed frameworks build phase
|
||||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||||
if embed_phase
|
if embed_phase
|
||||||
build_file = embed_phase.add_file_reference(framework_ref)
|
build_file = embed_phase.add_file_reference(framework_ref)
|
||||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||||
end
|
end
|
||||||
|
|
||||||
project.save
|
project.save
|
||||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
ruby add_framework.rb
|
ruby add_framework.rb
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||||
@@ -265,18 +273,44 @@ jobs:
|
|||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build iOS (unsigned)
|
- name: Build iOS (unsigned)
|
||||||
run: flutter build ios --release --no-codesign
|
run: |
|
||||||
|
# Build Flutter iOS without codesigning
|
||||||
|
flutter build ios --release --no-codesign --config-only
|
||||||
|
|
||||||
|
# Use xcodebuild with code signing disabled
|
||||||
|
cd ios
|
||||||
|
xcodebuild -workspace Runner.xcworkspace \
|
||||||
|
-scheme Runner \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath build/Runner.xcarchive \
|
||||||
|
archive \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
DEVELOPMENT_TEAM=""
|
||||||
|
|
||||||
- name: Create IPA
|
- name: Create IPA
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
mkdir -p build/ios/ipa
|
mkdir -p build/ios/ipa
|
||||||
cd build/ios/iphoneos
|
cd ios/build/Runner.xcarchive/Products/Applications
|
||||||
mkdir Payload
|
mkdir Payload
|
||||||
cp -r Runner.app Payload/
|
cp -r Runner.app Payload/
|
||||||
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
# Use absolute path to avoid relative path issues
|
||||||
|
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||||
rm -rf Payload
|
rm -rf Payload
|
||||||
|
|
||||||
|
- name: Verify IPA created
|
||||||
|
run: |
|
||||||
|
ls -la build/ios/ipa/
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then
|
||||||
|
echo "ERROR: IPA not created!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -288,7 +322,7 @@ jobs:
|
|||||||
needs: [get-version, build-android, build-ios]
|
needs: [get-version, build-android, build-ios]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -298,13 +332,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||||
|
|
||||||
echo "Looking for version: $VERSION_NUM"
|
echo "Looking for version: $VERSION_NUM"
|
||||||
|
|
||||||
# Extract changelog section for this version using sed
|
# Extract changelog section for this version using sed
|
||||||
# Find the line with version, then print until next version header or end
|
# Find the line with version, then print until next version header or end
|
||||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||||
|
|
||||||
# If no changelog found, use default message
|
# If no changelog found, use default message
|
||||||
if [ -z "$CHANGELOG" ]; then
|
if [ -z "$CHANGELOG" ]; then
|
||||||
echo "No changelog found for version $VERSION_NUM"
|
echo "No changelog found for version $VERSION_NUM"
|
||||||
@@ -312,7 +346,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Found changelog content"
|
echo "Found changelog content"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save to file for multiline support
|
# Save to file for multiline support
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
echo "Extracted changelog:"
|
echo "Extracted changelog:"
|
||||||
@@ -334,32 +368,34 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cat > /tmp/release_body.txt << 'HEADER'
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
## SpotiFLAC $VERSION
|
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### What's New
|
### What's New
|
||||||
HEADER
|
HEADER
|
||||||
|
|
||||||
# Replace $VERSION in header
|
|
||||||
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
|
||||||
|
|
||||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||||
|
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Downloads
|
### Downloads
|
||||||
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
|
||||||
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
#### 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)
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
|
  
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
|
# Documentation (hosted separately)
|
||||||
|
docs/
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
spotiflac_android/
|
spotiflac_android/
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,383 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [2.1.5] - 2026-01-08
|
## [2.2.9] - 2026-01-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||||
|
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
||||||
|
- Replaces existing entry and moves to top of list
|
||||||
|
- Auto-deduplicates existing history on app load
|
||||||
|
- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error
|
||||||
|
- Now shows proper message: "Cannot write to folder, check storage permission"
|
||||||
|
- Added `permission` error type detection in backend
|
||||||
|
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
|
||||||
|
- Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
|
||||||
|
- `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear)
|
||||||
|
- `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear)
|
||||||
|
- Proper permission check before showing "granted" status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.8] - 2026-01-12
|
||||||
|
|
||||||
### Added
|
### 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
|
||||||
|
|
||||||
|
- **Special Thanks Section**: Added new "Special Thanks" section in About page to credit API creators
|
||||||
|
- **uimaxbai** - Creator of QQDL & HiFi API for Tidal downloads
|
||||||
|
- **sachinsenal0x64** - Original HiFi project creator, foundation of Tidal integration
|
||||||
|
- **DoubleDouble** - Amazing API for Amazon Music downloads
|
||||||
|
- **DAB Music** - The best Qobuz streaming API for Hi-Res downloads
|
||||||
|
- **New Contributor**: Added Amonoman to Contributors section as the app logo creator
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing PlatformBridge Import**: Fixed build errors in `home_tab.dart` and `playlist_screen.dart`
|
||||||
|
- Added missing `import 'package:spotiflac_android/services/platform_bridge.dart'`
|
||||||
|
- **iOS Method Channel Crash**: Fixed "Method not implemented" crash when searching Deezer from iOS
|
||||||
|
- Implemented missing `searchDeezerAll` handler in `AppDelegate.swift`
|
||||||
|
- Ensures full compatibility with new Deezer integration features on iOS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.6] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Metadata Enrichment**: Automatically fetches full track details if metadata is incomplete (e.g., Track Number 0)
|
||||||
|
- Fixes missing Track Number, Disc Number, and Year for tracks added from Search results
|
||||||
|
- Ensures accurate tagging for Deezer/Tidal downloads
|
||||||
|
- **ISRC Index Building**: Fast duplicate checking with cached ISRC index
|
||||||
|
|
||||||
|
- Scans download folder once and builds index of all ISRCs
|
||||||
|
- 5 minute cache TTL for optimal performance
|
||||||
|
- Parallel duplicate checking for album/playlist tracks
|
||||||
|
- Auto-adds new downloads to index (no rebuild needed)
|
||||||
|
|
||||||
|
- **Japanese to Romaji Search**: Better search results for Japanese tracks
|
||||||
|
|
||||||
|
- Converts Hiragana/Katakana to Romaji for Tidal/Qobuz search
|
||||||
|
- 4 fallback search strategies (like PC version):
|
||||||
|
1. Original text (artist + track)
|
||||||
|
2. Romaji converted (artist + track)
|
||||||
|
3. ASCII-only cleaned version
|
||||||
|
4. Artist name only as last resort
|
||||||
|
- Handles combination characters (きゃ →kya, シャ →sha, etc.)
|
||||||
|
|
||||||
|
- **SongLink Deezer Support**: Query SongLink using Deezer ID as source
|
||||||
|
|
||||||
|
- `CheckAvailabilityFromDeezer()` - find track on other platforms using Deezer ID
|
||||||
|
- `CheckAvailabilityByPlatform()` - generic function for any platform
|
||||||
|
- `GetSpotifyIDFromDeezer()`, `GetTidalURLFromDeezer()`, `GetAmazonURLFromDeezer()`
|
||||||
|
- Useful when starting from Deezer metadata
|
||||||
|
|
||||||
|
- **LRC Metadata Headers**: Lyrics now include metadata headers
|
||||||
|
|
||||||
|
- `[ti:Track Name]` - track title
|
||||||
|
- `[ar:Artist Name]` - artist name
|
||||||
|
- `[by:SpotiFLAC-Mobile]` - generator tag
|
||||||
|
|
||||||
|
- **Download Error Types**: Better error categorization for UI
|
||||||
|
|
||||||
|
- `not_found` - track not available on any service
|
||||||
|
- `rate_limit` - API rate limit exceeded
|
||||||
|
- `network` - connection/timeout errors
|
||||||
|
- `unknown` - other errors
|
||||||
|
|
||||||
|
- **Amazon Rate Limiting**: Proper rate limiting for Amazon via SongLink
|
||||||
|
- 7 second minimum delay between requests
|
||||||
|
- Max 9 requests per minute
|
||||||
|
- 3x retry with 15s wait on 429 rate limit
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **SongLink 400 Error**: Added validation for empty Spotify ID
|
||||||
|
|
||||||
|
- Specific error messages for 400, 404, 429 status codes
|
||||||
|
- Better error handling for invalid track IDs
|
||||||
|
|
||||||
|
- **gomobile Compatibility**: Fixed `ISRCIndex.Lookup()` signature
|
||||||
|
- Changed from `(string, bool)` to `(string, error)` for gomobile binding
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- New file: `go_backend/romaji.go` with Japanese to Romaji conversion
|
||||||
|
- New file: `go_backend/duplicate.go` with ISRC index building
|
||||||
|
- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with romaji search strategies
|
||||||
|
- Updated `go_backend/songlink.go` with Deezer support functions
|
||||||
|
- Updated `go_backend/exports.go` with new export functions for Flutter
|
||||||
|
- Updated `go_backend/lyrics.go` with `convertToLRCWithMetadata()`
|
||||||
|
- Updated `go_backend/progress.go` with `SpeedMBps` field
|
||||||
|
- Updated `lib/models/download_item.dart` with `DownloadErrorType` enum
|
||||||
|
- Updated `lib/screens/queue_tab.dart` with speed display and error messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.6-preview] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
|
- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
|
||||||
|
|
||||||
- Configure in Settings > Options > Spotify API > Search Source
|
- Configure in Settings > Options > Spotify API > Search Source
|
||||||
- Default is Deezer for better reliability
|
- Default is Deezer for better reliability
|
||||||
- Spotify URLs are always supported regardless of this setting
|
- Spotify URLs are always supported regardless of this setting
|
||||||
|
|
||||||
- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer
|
- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer
|
||||||
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
|
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
|
||||||
- Fetches metadata from Deezer instead
|
- Fetches metadata from Deezer instead
|
||||||
- Works for tracks and albums (playlists are user-specific, artists require Spotify API)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Default Download Service**: Changed from Tidal to Qobuz
|
- **Default Download Service**: Changed from Tidal to Qobuz
|
||||||
- Fallback order is now: Qobuz → Tidal → Amazon
|
- Fallback order is now: Qobuz → Tidal → Amazon
|
||||||
- **Deezer API Updated to v2.0**: More reliable and complete metadata
|
- **Deezer API Updated to v2.0**: More reliable and complete metadata
|
||||||
@@ -20,6 +385,7 @@
|
|||||||
- Search results now fetch full track info to include ISRC
|
- Search results now fetch full track info to include ISRC
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
|
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
|
||||||
- Progress now updates smoothly every 64KB of data received
|
- Progress now updates smoothly every 64KB of data received
|
||||||
- First progress update happens immediately when download starts
|
- First progress update happens immediately when download starts
|
||||||
@@ -28,18 +394,17 @@
|
|||||||
- Incomplete files are automatically deleted and error is reported
|
- Incomplete files are automatically deleted and error is reported
|
||||||
- Applies to all services: Tidal, Qobuz, and Amazon
|
- Applies to all services: Tidal, Qobuz, and Amazon
|
||||||
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
|
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
|
||||||
- Improves track matching accuracy when downloading
|
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
- New settings field: `metadataSource` in `lib/models/settings.dart`
|
|
||||||
- New UI: Search Source selector in Options Settings page
|
|
||||||
- Improved `ItemProgressWriter` with threshold-based progress updates
|
|
||||||
- Download functions now properly handle network interruptions
|
|
||||||
- Deezer API base URL changed to `https://api.deezer.com/2.0`
|
|
||||||
|
|
||||||
## [2.1.0] - 2026-01-06
|
- Settings migration for existing users to set Deezer as default metadata source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.5] - 2026-01-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
- Service selector chips appear above quality options
|
- Service selector chips appear above quality options
|
||||||
- Defaults to your preferred service from settings
|
- Defaults to your preferred service from settings
|
||||||
@@ -55,6 +420,7 @@
|
|||||||
- Configure in Settings > Options > App
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
- arm64 APK: 46.6 MB (previously 51 MB)
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
- arm32 APK: 59 MB (previously 64 MB)
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
@@ -64,6 +430,7 @@
|
|||||||
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
- Now properly handles retry when queue processing has finished
|
- Now properly handles retry when queue processing has finished
|
||||||
- Also allows retrying skipped (cancelled) downloads
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
@@ -75,6 +442,7 @@
|
|||||||
- Files saved to app Documents folder are accessible via iOS Files app
|
- Files saved to app Documents folder are accessible via iOS Files app
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
- Token caching for Tidal (eliminates redundant auth requests)
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
- Singleton pattern for all downloaders (HTTP connection reuse)
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
@@ -90,6 +458,7 @@
|
|||||||
## [2.1.0-preview2] - 2026-01-06
|
## [2.1.0-preview2] - 2026-01-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
- Service selector chips appear above quality options
|
- Service selector chips appear above quality options
|
||||||
- Defaults to your preferred service from settings
|
- Defaults to your preferred service from settings
|
||||||
@@ -105,6 +474,7 @@
|
|||||||
- Configure in Settings > Options > App
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
- Now properly handles retry when queue processing has finished
|
- Now properly handles retry when queue processing has finished
|
||||||
- Also allows retrying skipped (cancelled) downloads
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
@@ -116,6 +486,7 @@
|
|||||||
## [2.1.0-preview] - 2026-01-06
|
## [2.1.0-preview] - 2026-01-06
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
- Token caching for Tidal (eliminates redundant auth requests)
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
- Singleton pattern for all downloaders (HTTP connection reuse)
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
@@ -129,6 +500,7 @@
|
|||||||
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
|
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
|
||||||
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
|
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
|
||||||
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
|
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
|
||||||
@@ -136,6 +508,7 @@
|
|||||||
## [2.0.7-preview2] - 2026-01-06
|
## [2.0.7-preview2] - 2026-01-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
||||||
- iOS limitation: Empty folders cannot be selected via document picker
|
- iOS limitation: Empty folders cannot be selected via document picker
|
||||||
- Added "App Documents Folder" option as recommended default
|
- Added "App Documents Folder" option as recommended default
|
||||||
@@ -145,6 +518,7 @@
|
|||||||
## [2.0.7-preview] - 2026-01-05
|
## [2.0.7-preview] - 2026-01-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
- arm64 APK: 46.6 MB (previously 51 MB)
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
- arm32 APK: 59 MB (previously 64 MB)
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
@@ -152,6 +526,7 @@
|
|||||||
- Removed x86/x86_64 architectures (emulator only)
|
- Removed x86/x86_64 architectures (emulator only)
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
||||||
- Native MethodChannel bridge for FFmpeg operations
|
- Native MethodChannel bridge for FFmpeg operations
|
||||||
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
@@ -159,6 +534,7 @@
|
|||||||
## [2.0.6] - 2026-01-05
|
## [2.0.6] - 2026-01-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
||||||
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
||||||
- Now properly converts milliseconds to seconds before display
|
- Now properly converts milliseconds to seconds before display
|
||||||
@@ -178,14 +554,17 @@
|
|||||||
## [2.0.5] - 2026-01-05
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
||||||
|
|
||||||
## [2.0.4] - 2026-01-04
|
## [2.0.4] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
||||||
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
||||||
- Shows explanation dialog before opening system settings
|
- Shows explanation dialog before opening system settings
|
||||||
@@ -193,6 +572,7 @@
|
|||||||
## [2.0.3] - 2026-01-03
|
## [2.0.3] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
||||||
- Toggle to enable/disable custom credentials without deleting them
|
- Toggle to enable/disable custom credentials without deleting them
|
||||||
- Material Expressive 3 bottom sheet UI for entering credentials
|
- Material Expressive 3 bottom sheet UI for entering credentials
|
||||||
@@ -200,9 +580,11 @@
|
|||||||
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
||||||
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
||||||
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
||||||
@@ -212,6 +594,7 @@
|
|||||||
## [2.0.2] - 2026-01-03
|
## [2.0.2] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
||||||
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
||||||
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
||||||
@@ -220,13 +603,16 @@
|
|||||||
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
||||||
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
||||||
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
||||||
- Tidal API v2 response provides exact quality info
|
- Tidal API v2 response provides exact quality info
|
||||||
@@ -236,18 +622,21 @@
|
|||||||
## [2.0.1] - 2026-01-03
|
## [2.0.1] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||||
- Tap to expand long track titles
|
- Tap to expand long track titles
|
||||||
- Expand icon only shows when title is truncated
|
- Expand icon only shows when title is truncated
|
||||||
- Ripple effect follows rounded corners including drag handle
|
- Ripple effect follows rounded corners including drag handle
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
||||||
- All downloads now use item-based progress tracking
|
- All downloads now use item-based progress tracking
|
||||||
- Fixes duplicate notification bug when finalizing
|
- Fixes duplicate notification bug when finalizing
|
||||||
- Cleaner codebase with single progress system
|
- Cleaner codebase with single progress system
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
||||||
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
||||||
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
||||||
@@ -257,6 +646,7 @@
|
|||||||
## [2.0.0] - 2026-01-03
|
## [2.0.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Artist Search Results**: Search now shows artists alongside tracks
|
- **Artist Search Results**: Search now shows artists alongside tracks
|
||||||
- Horizontal scrollable artist cards with circular avatars
|
- Horizontal scrollable artist cards with circular avatars
|
||||||
- Tap artist to view their discography
|
- Tap artist to view their discography
|
||||||
@@ -277,11 +667,12 @@
|
|||||||
- Stable users won't receive update notifications for preview versions
|
- Stable users won't receive update notifications for preview versions
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
||||||
- Header (name, cover) shows instantly from available data
|
- Header (name, cover) shows instantly from available data
|
||||||
- Content (albums/tracks) loads in background inside the screen
|
- Content (albums/tracks) loads in background inside the screen
|
||||||
- Second visit to same artist/album is instant from Flutter cache
|
- Second visit to same artist/album is instant from Flutter cache
|
||||||
- **Search Results UI Redesign**:
|
- **Search Results UI Redesign**:
|
||||||
- Removed "Download All" button from search results
|
- Removed "Download All" button from search results
|
||||||
- Added "Songs" section header (matches "Artists" header style)
|
- Added "Songs" section header (matches "Artists" header style)
|
||||||
- Track list now in grouped card with rounded corners (like Settings)
|
- Track list now in grouped card with rounded corners (like Settings)
|
||||||
@@ -304,6 +695,7 @@
|
|||||||
- **Ask Before Download Default**: Now enabled by default for better UX
|
- **Ask Before Download Default**: Now enabled by default for better UX
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
||||||
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
||||||
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
||||||
@@ -314,6 +706,7 @@
|
|||||||
## [1.6.3] - 2026-01-03
|
## [1.6.3] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
||||||
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
||||||
- Collapsing header with cover art and gradient overlay
|
- Collapsing header with cover art and gradient overlay
|
||||||
@@ -323,6 +716,7 @@
|
|||||||
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
||||||
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
||||||
- Enables native predictive back gesture animations
|
- Enables native predictive back gesture animations
|
||||||
@@ -332,17 +726,21 @@
|
|||||||
## [1.6.2] - 2026-01-02
|
## [1.6.2] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
||||||
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
||||||
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
||||||
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
||||||
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
||||||
@@ -354,12 +752,14 @@
|
|||||||
## [1.6.1] - 2026-01-02
|
## [1.6.1] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Background Download Service**: Downloads now continue running when app is in background
|
- **Background Download Service**: Downloads now continue running when app is in background
|
||||||
- Foreground service with wake lock prevents Android from killing downloads
|
- Foreground service with wake lock prevents Android from killing downloads
|
||||||
- Persistent notification shows download progress
|
- Persistent notification shows download progress
|
||||||
- No more "connection abort" errors when switching apps
|
- No more "connection abort" errors when switching apps
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
||||||
- Download queue is now persisted to storage and automatically restored on app restart
|
- Download queue is now persisted to storage and automatically restored on app restart
|
||||||
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
||||||
@@ -368,11 +768,13 @@
|
|||||||
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
||||||
|
|
||||||
## [1.6.0] - 2026-01-02
|
## [1.6.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Manual Quality Selection**: New option to choose audio quality before each download
|
- **Manual Quality Selection**: New option to choose audio quality before each download
|
||||||
- Toggle "Ask Before Download" in Download Settings
|
- Toggle "Ask Before Download" in Download Settings
|
||||||
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
||||||
@@ -387,12 +789,14 @@
|
|||||||
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
||||||
- Users on hotfix versions now properly receive update notifications
|
- Users on hotfix versions now properly receive update notifications
|
||||||
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
||||||
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
||||||
- Items in same group are connected with rounded card container
|
- Items in same group are connected with rounded card container
|
||||||
- Section headers outside cards for clear visual hierarchy
|
- Section headers outside cards for clear visual hierarchy
|
||||||
@@ -401,6 +805,7 @@
|
|||||||
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
||||||
- **Dependencies Updated**:
|
- **Dependencies Updated**:
|
||||||
- `share_plus`: 10.1.4 → 12.0.1
|
- `share_plus`: 10.1.4 → 12.0.1
|
||||||
@@ -410,6 +815,7 @@
|
|||||||
## [1.5.5] - 2026-01-02
|
## [1.5.5] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
|
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
|
||||||
- Supports track, album, playlist, and artist URLs
|
- Supports track, album, playlist, and artist URLs
|
||||||
- Auto-fetches metadata when link is shared
|
- Auto-fetches metadata when link is shared
|
||||||
@@ -436,6 +842,7 @@
|
|||||||
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
|
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
|
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
|
||||||
- Shows download queue at top when active
|
- Shows download queue at top when active
|
||||||
- Completed downloads auto-move to history section
|
- Completed downloads auto-move to history section
|
||||||
@@ -446,11 +853,13 @@
|
|||||||
- Only shows exit dialog when truly at root
|
- Only shows exit dialog when truly at root
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
|
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
|
||||||
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
|
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
|
||||||
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
|
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
|
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
|
||||||
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
|
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
|
||||||
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
|
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
|
||||||
@@ -459,26 +868,31 @@
|
|||||||
## [1.5.0-hotfix6] - 2026-01-02
|
## [1.5.0-hotfix6] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
|
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
|
||||||
|
|
||||||
## [1.5.0-hotfix5] - 2026-01-02
|
## [1.5.0-hotfix5] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Use key.properties as per Flutter official documentation
|
- **App Signing**: Use key.properties as per Flutter official documentation
|
||||||
|
|
||||||
## [1.5.0-hotfix4] - 2026-01-02
|
## [1.5.0-hotfix4] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Create keystore.properties in workflow for Gradle
|
- **App Signing**: Create keystore.properties in workflow for Gradle
|
||||||
|
|
||||||
## [1.5.0-hotfix] - 2026-01-02
|
## [1.5.0-hotfix] - 2026-01-02
|
||||||
|
|
||||||
### Important Notice
|
### Important Notice
|
||||||
|
|
||||||
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
|
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
|
||||||
|
|
||||||
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
|
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **In-App Update**: Download and install updates directly from the app
|
- **In-App Update**: Download and install updates directly from the app
|
||||||
- Progress bar shows download status
|
- Progress bar shows download status
|
||||||
- Automatic device architecture detection (arm64/arm32)
|
- Automatic device architecture detection (arm64/arm32)
|
||||||
@@ -486,11 +900,13 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Consistent App Signing**: All future releases will use the same signing key
|
- **Consistent App Signing**: All future releases will use the same signing key
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Update Checker**: Now downloads APK directly instead of opening browser
|
- **Update Checker**: Now downloads APK directly instead of opening browser
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-02
|
## [1.5.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
||||||
- Progress bar in notification during download
|
- Progress bar in notification during download
|
||||||
- Completion notification when track finishes
|
- Completion notification when track finishes
|
||||||
@@ -514,6 +930,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Downloads correct APK for your device
|
- Downloads correct APK for your device
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
||||||
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
||||||
- Removed global pause/resume in favor of per-item controls
|
- Removed global pause/resume in favor of per-item controls
|
||||||
@@ -538,6 +955,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- "Add Music" button for quick access
|
- "Add Music" button for quick access
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Added `flutter_local_notifications` package for notifications
|
- Added `flutter_local_notifications` package for notifications
|
||||||
- Added notification permission request in setup screen for Android 13+
|
- Added notification permission request in setup screen for Android 13+
|
||||||
- Enabled core library desugaring for all Android subprojects
|
- Enabled core library desugaring for all Android subprojects
|
||||||
@@ -546,6 +964,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
||||||
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
||||||
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
||||||
@@ -554,6 +973,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
## [1.2.0] - 2026-01-02
|
## [1.2.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
||||||
- Material Expressive 3 design with cover art header and gradient
|
- Material Expressive 3 design with cover art header and gradient
|
||||||
- Hero animation from list to detail view
|
- Hero animation from list to detail view
|
||||||
@@ -566,12 +986,14 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
|
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
|
||||||
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
|
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
|
||||||
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
|
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
|
||||||
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
|
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
||||||
- Play button still available for quick playback
|
- Play button still available for quick playback
|
||||||
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
||||||
@@ -580,30 +1002,34 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
## [1.1.2] - 2026-01-01
|
## [1.1.2] - 2026-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Update Checker**: Automatic check for new versions from GitHub releases
|
- **Update Checker**: Automatic check for new versions from GitHub releases
|
||||||
- Shows changelog in update dialog
|
- Shows changelog in update dialog
|
||||||
- Option to disable update notifications
|
- Option to disable update notifications
|
||||||
- **Release Changelog**: GitHub releases now include full changelog
|
- **Release Changelog**: GitHub releases now include full changelog
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated version to 1.1.2
|
- Updated version to 1.1.2
|
||||||
|
|
||||||
## [1.1.1] - 2026-01-01
|
## [1.1.1] - 2026-01-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **About Dialog**: Custom About dialog with cleaner layout
|
- **About Dialog**: Custom About dialog with cleaner layout
|
||||||
- **Setup Screen**: Fixed step indicator line alignment
|
- **Setup Screen**: Fixed step indicator line alignment
|
||||||
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
||||||
- **Copyright Year**: Updated to 2026
|
- **Copyright Year**: Updated to 2026
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Removed Theme Preview from Settings
|
- Removed Theme Preview from Settings
|
||||||
- Added MIT License
|
- Added MIT License
|
||||||
|
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-01
|
## [1.1.0] - 2026-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
||||||
- Default: Sequential (1 at a time) for stability
|
- Default: Sequential (1 at a time) for stability
|
||||||
- Options: 1, 2, or 3 concurrent downloads
|
- Options: 1, 2, or 3 concurrent downloads
|
||||||
@@ -614,15 +1040,18 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||||
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
||||||
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
||||||
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated version to 1.1.0
|
- Updated version to 1.1.0
|
||||||
|
|
||||||
### Technical Details
|
### Technical Details
|
||||||
|
|
||||||
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
||||||
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
||||||
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
||||||
@@ -631,6 +1060,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Added `CleanupConnections()` export for Flutter to call via method channel
|
- Added `CleanupConnections()` export for Flutter to call via method channel
|
||||||
|
|
||||||
## [1.0.5] - Previous Release
|
## [1.0.5] - Previous Release
|
||||||
|
|
||||||
- Material Expressive 3 UI
|
- Material Expressive 3 UI
|
||||||
- Dynamic color support
|
- Dynamic color support
|
||||||
- Swipe navigation with PageView
|
- Swipe navigation with PageView
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/9092dd9300289ceadd8e70cd71706a3ba32225d9cb2ae8b12648611d31814708)
|
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ SpotiFLAC supports two metadata sources for searching tracks:
|
|||||||
To use Spotify as your search source without hitting rate limits:
|
To use Spotify as your search source without hitting rate limits:
|
||||||
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
||||||
2. Create an app to get your Client ID and Client Secret
|
2. Create an app to get your Client ID and Client Secret
|
||||||
3. Go to **Settings > Options > Spotify API > Custom Credentials**
|
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
|
||||||
4. Enter your Client ID and Secret
|
4. Enter your Client ID and Secret
|
||||||
5. Change **Search Source** to Spotify
|
5. Change **Search Source** to Spotify
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -7,9 +10,9 @@ plugins {
|
|||||||
|
|
||||||
// Load keystore properties for local builds
|
// Load keystore properties for local builds
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
val keystoreProperties = java.util.Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -32,10 +35,10 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
create("release") {
|
create("release") {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
storePassword = keystoreProperties["storePassword"] as String
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 34
|
targetSdk = 36
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -94,8 +97,10 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
|
||||||
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
|
// Include all AAR and JAR files from libs folder
|
||||||
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.**
|
||||||
|
-dontwarn com.google.android.play.core.tasks.**
|
||||||
|
|
||||||
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar)
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
@@ -14,6 +22,9 @@
|
|||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
|
||||||
|
# Apache Tika (if used by FFmpeg)
|
||||||
|
-dontwarn org.apache.tika.**
|
||||||
|
|
||||||
# Keep native methods
|
# Keep native methods
|
||||||
-keepclasseswithmembernames class * {
|
-keepclasseswithmembernames class * {
|
||||||
native <methods>;
|
native <methods>;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
|
|||||||
/**
|
/**
|
||||||
* Foreground service to keep downloads running when app is in background.
|
* Foreground service to keep downloads running when app is in background.
|
||||||
* This prevents Android from killing the download process or throttling network.
|
* This prevents Android from killing the download process or throttling network.
|
||||||
|
*
|
||||||
|
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
|
||||||
|
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
|
||||||
*/
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
@@ -106,6 +109,19 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the foreground service timeout is reached (Android 15+, API 35+).
|
||||||
|
* dataSync services have a 6-hour limit in a 24-hour period.
|
||||||
|
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||||
|
*/
|
||||||
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
// Log the timeout for debugging
|
||||||
|
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||||
|
|
||||||
|
// Gracefully stop the service
|
||||||
|
stopForegroundService()
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"readFileMetadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.readFileMetadata(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"startDownloadService" -> {
|
"startDownloadService" -> {
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
@@ -277,6 +284,39 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
// Log methods
|
||||||
|
"getLogs" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogs()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getLogsSince" -> {
|
||||||
|
val index = call.argument<Int>("index") ?: 0
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogsSince(index.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"clearLogs" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearLogs()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getLogCount" -> {
|
||||||
|
val count = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogCount()
|
||||||
|
}
|
||||||
|
result.success(count.toInt())
|
||||||
|
}
|
||||||
|
"setLoggingEnabled" -> {
|
||||||
|
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 651 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -105,6 +105,78 @@ class FFmpegServiceIOS {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed metadata and cover art to FLAC file
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedMetadata({
|
||||||
|
required String flacPath,
|
||||||
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
|
||||||
|
// Construct command
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
|
// Add cover input if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map audio stream
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
// Map cover stream if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v copy ');
|
||||||
|
cmdBuffer.write('-disposition:v attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy audio codec (don't re-encode)
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
// Add text metadata
|
||||||
|
if (metadata != null) {
|
||||||
|
metadata.forEach((key, value) {
|
||||||
|
// Sanitize value: escape double quotes
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg command: $command');
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
/// Check if FFmpeg is available
|
||||||
static Future<bool> isAvailable() async {
|
static Future<bool> isAvailable() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ import (
|
|||||||
|
|
||||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||||
type AmazonDownloader struct {
|
type AmazonDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
regions []string // us, eu regions for DoubleDouble service
|
regions []string // us, eu regions for DoubleDouble service
|
||||||
|
lastAPICallTime time.Time // Rate limiting: track last API call
|
||||||
|
apiCallCount int // Rate limiting: counter per minute
|
||||||
|
apiCallResetTime time.Time // Rate limiting: reset time
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Amazon downloader instance for connection reuse
|
// Global Amazon downloader instance for connection reuse
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
@@ -48,46 +52,46 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
// Check if one contains the other
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
if expectedFirst == foundFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
// assume they're the same artist with different transliteration
|
// assume they're the same artist with different transliteration
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedASCII != foundASCII {
|
||||||
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,13 +109,55 @@ func amazonIsASCIIString(s string) bool {
|
|||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
regions: []string{"us", "eu"}, // Same regions as PC
|
||||||
|
apiCallResetTime: time.Now(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForRateLimit implements rate limiting similar to PC version
|
||||||
|
// Max 9 requests per minute with 7 second delay between requests
|
||||||
|
func (a *AmazonDownloader) waitForRateLimit() {
|
||||||
|
amazonRateLimitMu.Lock()
|
||||||
|
defer amazonRateLimitMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Reset counter every minute
|
||||||
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
|
a.apiCallCount = 0
|
||||||
|
a.apiCallResetTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've hit the limit (9 requests per minute), wait until next minute
|
||||||
|
if a.apiCallCount >= 9 {
|
||||||
|
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||||
|
if waitTime > 0 {
|
||||||
|
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
a.apiCallCount = 0
|
||||||
|
a.apiCallResetTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay between requests (7 seconds like PC version)
|
||||||
|
if !a.lastAPICallTime.IsZero() {
|
||||||
|
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||||
|
minDelay := 7 * time.Second
|
||||||
|
if timeSinceLastCall < minDelay {
|
||||||
|
waitTime := minDelay - timeSinceLastCall
|
||||||
|
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tracking
|
||||||
|
a.lastAPICallTime = time.Now()
|
||||||
|
a.apiCallCount++
|
||||||
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||||
// Uses same service as PC version (doubledouble.top)
|
// Uses same service as PC version (doubledouble.top)
|
||||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||||
@@ -124,7 +170,6 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||||
// This uses submit → poll → download mechanism
|
// This uses submit → poll → download mechanism
|
||||||
// Internal function - not exported to gomobile
|
// Internal function - not exported to gomobile
|
||||||
@@ -132,18 +177,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
var lastError error
|
var lastError error
|
||||||
|
|
||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||||
|
|
||||||
// Build base URL for DoubleDouble service
|
// Build base URL for DoubleDouble service
|
||||||
// Decode base64 service URL (same as PC)
|
// Decode base64 service URL (same as PC)
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
|
|
||||||
// Step 1: Submit download request
|
// Step 1: Submit download request with rate limiting
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
encodedURL := url.QueryEscape(amazonURL)
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||||
|
|
||||||
|
// Apply rate limiting before request (like PC version)
|
||||||
|
a.waitForRateLimit()
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", submitURL, nil)
|
req, err := http.NewRequest("GET", submitURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -153,15 +201,43 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
fmt.Println("[Amazon] Submitting download request...")
|
fmt.Println("[Amazon] Submitting download request...")
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
||||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
var resp *http.Response
|
||||||
continue
|
maxRetries := 3
|
||||||
|
for retry := 0; retry < maxRetries; retry++ {
|
||||||
|
resp, err = a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 { // Too Many Requests
|
||||||
|
resp.Body.Close()
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
waitTime := 15 * time.Second
|
||||||
|
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
resp.Body.Close()
|
||||||
|
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - break retry loop
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if err != nil || lastError != nil {
|
||||||
resp.Body.Close()
|
if resp != nil {
|
||||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
resp.Body.Close()
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
downloadID := submitResp.ID
|
||||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
// Step 2: Poll for completion
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||||
@@ -234,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
trackName := status.Current.Name
|
trackName := status.Current.Name
|
||||||
artist := status.Current.Artist
|
artist := status.Current.Artist
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||||
return fileURL, trackName, artist, nil
|
return fileURL, trackName, artist, nil
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
} else if status.Status == "error" {
|
||||||
@@ -268,7 +344,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
@@ -348,9 +423,16 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
@@ -365,7 +447,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
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:")
|
||||||
|
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
|
} else if req.SpotifyID != "" {
|
||||||
|
// Use Spotify ID
|
||||||
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
|
} else {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
@@ -389,12 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Verify artist matches
|
// Verify artist matches
|
||||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
// Log match found
|
||||||
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
@@ -445,19 +542,38 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
if trackName != "" && artistName != "" {
|
if trackName != "" && artistName != "" {
|
||||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing metadata from downloaded file BEFORE embedding
|
||||||
|
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
|
||||||
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
|
actualTrackNum := req.TrackNumber
|
||||||
|
actualDiscNum := req.DiscNumber
|
||||||
|
|
||||||
|
if metaErr == nil && existingMeta != nil {
|
||||||
|
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
||||||
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||||
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||||
|
// But preserve track/disc numbers from file if they were better
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: actualTrackNum,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +581,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
@@ -474,9 +590,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||||
}
|
}
|
||||||
@@ -485,24 +601,50 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
|
|
||||||
// Read actual quality from the downloaded FLAC file
|
// Read actual quality from the downloaded FLAC file
|
||||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
// Return 0 to indicate unknown quality
|
} else {
|
||||||
return AmazonDownloadResult{
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: 0,
|
|
||||||
SampleRate: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: quality.BitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: quality.SampleRate,
|
SampleRate: sampleRate,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: actualTrackNum,
|
||||||
|
DiscNumber: actualDiscNum,
|
||||||
|
ISRC: req.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ const (
|
|||||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// Parallel ISRC fetching settings
|
||||||
|
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeezerClient handles Deezer API interactions (no auth required)
|
// DeezerClient handles Deezer API interactions (no auth required)
|
||||||
@@ -29,6 +32,7 @@ type DeezerClient struct {
|
|||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
|
isrcCache map[string]string // trackID -> ISRC cache
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +50,7 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
searchCache: make(map[string]*cacheEntry),
|
searchCache: make(map[string]*cacheEntry),
|
||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
artistCache: make(map[string]*cacheEntry),
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
isrcCache: make(map[string]string),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return deezerClient
|
return deezerClient
|
||||||
@@ -53,26 +58,27 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
|
|
||||||
// Deezer API response types
|
// Deezer API response types
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int `json:"duration"` // in seconds
|
Duration int `json:"duration"` // in seconds
|
||||||
TrackPosition int `json:"track_position"`
|
TrackPosition int `json:"track_position"`
|
||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Artist deezerArtist `json:"artist"`
|
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||||
Album deezerAlbumSimple `json:"album"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Album deezerAlbumSimple `json:"album"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerArtist struct {
|
type deezerArtist struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbFan int `json:"nb_fan"`
|
NbFan int `json:"nb_fan"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerAlbumSimple struct {
|
type deezerAlbumSimple struct {
|
||||||
@@ -82,20 +88,67 @@ type deezerAlbumSimple struct {
|
|||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
|
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (skip other structs as they are fine/unchanged) ...
|
||||||
|
|
||||||
|
// ... (in convertTrack) ...
|
||||||
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
|
artistName := track.Artist.Name
|
||||||
|
if len(track.Contributors) > 0 {
|
||||||
|
names := make([]string, len(track.Contributors))
|
||||||
|
for i, a := range track.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find release date
|
||||||
|
releaseDate := track.ReleaseDate
|
||||||
|
if releaseDate == "" {
|
||||||
|
releaseDate = track.Album.ReleaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: artistName,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: releaseDate, // Added this
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerAlbumFull struct {
|
type deezerAlbumFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
@@ -112,28 +165,32 @@ type deezerArtistFull struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deezerPlaylistFull struct {
|
type deezerPlaylistFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
Creator struct {
|
Creator struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"creator"`
|
} `json:"creator"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAll searches for tracks and artists on Deezer
|
// SearchAll searches for tracks and artists on Deezer
|
||||||
|
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||||
return entry.data.(*SearchAllResult), nil
|
return entry.data.(*SearchAllResult), nil
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -143,43 +200,68 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
Artists: make([]SearchArtistResult, 0),
|
Artists: make([]SearchArtistResult, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search tracks
|
// Search tracks - NO ISRC fetch for performance
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if trackResp.Error != nil {
|
||||||
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
// Fetch full track info to get ISRC (search results don't include ISRC)
|
// Convert directly without fetching ISRC - much faster
|
||||||
fullTrack, err := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
if err == nil && fullTrack != nil {
|
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(*fullTrack))
|
|
||||||
} else {
|
|
||||||
// Fallback to search result without ISRC
|
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search artists
|
// Search artists
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
var artistResp struct {
|
var artistResp struct {
|
||||||
Data []deezerArtist `json:"data"`
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
for _, artist := range artistResp.Data {
|
if artistResp.Error != nil {
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
} else {
|
||||||
Name: artist.Name,
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
Images: c.getBestArtistImage(artist),
|
for _, artist := range artistResp.Data {
|
||||||
Followers: artist.NbFan,
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
Popularity: 0,
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
})
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||||
|
|
||||||
// Cache result
|
// Cache result
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
@@ -194,7 +276,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
// GetTrack fetches a single track by Deezer ID
|
// GetTrack fetches a single track by Deezer ID
|
||||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
var track deezerTrack
|
var track deezerTrack
|
||||||
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -206,6 +288,7 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbum fetches album with tracks
|
// GetAlbum fetches album with tracks
|
||||||
|
// ISRC is fetched in parallel for better performance
|
||||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
@@ -215,7 +298,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
var album deezerAlbumFull
|
var album deezerAlbumFull
|
||||||
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -239,14 +322,13 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||||
for _, track := range album.Tracks.Data {
|
for _, track := range album.Tracks.Data {
|
||||||
// Need to fetch full track info for ISRC
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
isrc := isrcMap[trackIDStr]
|
||||||
isrc := ""
|
|
||||||
if fullTrack != nil {
|
|
||||||
isrc = fullTrack.ISRC
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
@@ -328,7 +410,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
if albumType == "compile" {
|
if albumType == "compile" {
|
||||||
albumType = "compilation"
|
albumType = "compilation"
|
||||||
}
|
}
|
||||||
|
|
||||||
coverURL := album.CoverXL
|
coverURL := album.CoverXL
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
coverURL = album.CoverBig
|
coverURL = album.CoverBig
|
||||||
@@ -368,9 +450,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPlaylist fetches playlist with tracks
|
// GetPlaylist fetches playlist with tracks
|
||||||
|
// ISRC is fetched in parallel for better performance
|
||||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
var playlist deezerPlaylistFull
|
var playlist deezerPlaylistFull
|
||||||
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,6 +473,9 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
info.Owner.Name = playlist.Title
|
info.Owner.Name = playlist.Title
|
||||||
info.Owner.Images = playlistImage
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||||
for _, track := range playlist.Tracks.Data {
|
for _, track := range playlist.Tracks.Data {
|
||||||
albumImage := track.Album.CoverXL
|
albumImage := track.Album.CoverXL
|
||||||
@@ -400,13 +486,8 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
albumImage = track.Album.CoverMedium
|
albumImage = track.Album.CoverMedium
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch full track for ISRC
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
isrc := isrcMap[trackIDStr]
|
||||||
isrc := ""
|
|
||||||
releaseDate := ""
|
|
||||||
if fullTrack != nil {
|
|
||||||
isrc = fullTrack.ISRC
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
@@ -416,7 +497,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: releaseDate,
|
ReleaseDate: "",
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: track.TrackPosition,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
@@ -436,7 +517,7 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
|||||||
// Use direct ISRC endpoint (API 2.0)
|
// Use direct ISRC endpoint (API 2.0)
|
||||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||||
|
|
||||||
var track deezerTrack
|
var track deezerTrack
|
||||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||||
// Fallback to search if direct endpoint fails
|
// Fallback to search if direct endpoint fails
|
||||||
@@ -472,40 +553,89 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
||||||
artistName := track.Artist.Name
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
if len(track.Contributors) > 0 {
|
result := make(map[string]string)
|
||||||
names := make([]string, len(track.Contributors))
|
var resultMu sync.Mutex
|
||||||
for i, a := range track.Contributors {
|
|
||||||
names[i] = a.Name
|
// First, check cache for existing ISRCs
|
||||||
|
var tracksToFetch []deezerTrack
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
for _, track := range tracks {
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
||||||
|
result[trackIDStr] = isrc
|
||||||
|
} else {
|
||||||
|
tracksToFetch = append(tracksToFetch, track)
|
||||||
}
|
}
|
||||||
artistName = strings.Join(names, ", ")
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
if len(tracksToFetch) == 0 {
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
albumImage := track.Album.CoverXL
|
// Use semaphore to limit concurrent requests
|
||||||
if albumImage == "" {
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
albumImage = track.Album.CoverBig
|
var wg sync.WaitGroup
|
||||||
}
|
|
||||||
if albumImage == "" {
|
for _, track := range tracksToFetch {
|
||||||
albumImage = track.Album.CoverMedium
|
wg.Add(1)
|
||||||
}
|
go func(t deezerTrack) {
|
||||||
if albumImage == "" {
|
defer wg.Done()
|
||||||
albumImage = track.Album.Cover
|
|
||||||
|
// Acquire semaphore
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return TrackMetadata{
|
wg.Wait()
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
return result
|
||||||
Artists: artistName,
|
}
|
||||||
Name: track.Title,
|
|
||||||
AlbumName: track.Album.Title,
|
// GetTrackISRC fetches ISRC for a single track (with caching)
|
||||||
AlbumArtist: track.Artist.Name,
|
// Use this when you need ISRC for download
|
||||||
DurationMS: track.Duration * 1000,
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
Images: albumImage,
|
// Check cache first
|
||||||
TrackNumber: track.TrackPosition,
|
c.cacheMu.RLock()
|
||||||
DiscNumber: track.DiskNumber,
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
ExternalURL: track.Link,
|
c.cacheMu.RUnlock()
|
||||||
ISRC: track.ISRC,
|
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 {
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
@@ -590,7 +720,7 @@ func parseDeezerURL(input string) (string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
|
|||||||
@@ -1,49 +1,144 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
||||||
|
type ISRCIndex struct {
|
||||||
|
index map[string]string // ISRC (uppercase) -> file path
|
||||||
|
outputDir string
|
||||||
|
buildTime time.Time
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global ISRC index cache (per output directory)
|
||||||
|
var (
|
||||||
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||||
|
isrcIndexCacheMu sync.RWMutex
|
||||||
|
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetISRCIndex returns or builds an ISRC index for the given directory
|
||||||
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Return cached index if still valid
|
||||||
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new index
|
||||||
|
return buildISRCIndex(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||||
|
// Same implementation as PC version for consistency
|
||||||
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
idx := &ISRCIndex{
|
||||||
|
index: make(map[string]string),
|
||||||
|
outputDir: outputDir,
|
||||||
|
buildTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputDir == "" {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
fileCount := 0
|
||||||
|
|
||||||
|
// Walk directory - only check .flac files
|
||||||
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if ext != ".flac" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read ISRC from file
|
||||||
|
metadata, err := ReadMetadata(path)
|
||||||
|
if err != nil || metadata.ISRC == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in index (uppercase for case-insensitive matching)
|
||||||
|
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||||
|
fileCount++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
|
// Cache the index
|
||||||
|
isrcIndexCacheMu.Lock()
|
||||||
|
isrcIndexCache[outputDir] = idx
|
||||||
|
isrcIndexCacheMu.Unlock()
|
||||||
|
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup checks if an ISRC exists in the index (internal, returns bool)
|
||||||
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||||
|
if isrc == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.RLock()
|
||||||
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
|
path, exists := idx.index[strings.ToUpper(isrc)]
|
||||||
|
return path, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||||
|
// Returns filepath if found, empty string if not found
|
||||||
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
|
path, _ := idx.lookup(isrc)
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a new ISRC to the index (call after successful download)
|
||||||
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||||
|
if isrc == "" || filePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.Lock()
|
||||||
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
|
idx.index[strings.ToUpper(isrc)] = filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateCache clears the ISRC index cache for a directory
|
||||||
|
func InvalidateISRCCache(outputDir string) {
|
||||||
|
isrcIndexCacheMu.Lock()
|
||||||
|
delete(isrcIndexCache, outputDir)
|
||||||
|
isrcIndexCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
||||||
|
// Uses ISRC index for fast lookup
|
||||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||||
if isrc == "" || outputDir == "" {
|
if isrc == "" || outputDir == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk through directory looking for FLAC files
|
// Use index for fast lookup
|
||||||
var foundFile string
|
idx := GetISRCIndex(outputDir)
|
||||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
return idx.lookup(isrc)
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only check FLAC files
|
|
||||||
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".flac") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read metadata from file
|
|
||||||
metadata, err := ReadMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ISRC matches
|
|
||||||
if metadata.ISRC == isrc {
|
|
||||||
foundFile = path
|
|
||||||
return filepath.SkipAll // Stop walking
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if foundFile != "" {
|
|
||||||
return foundFile, true
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||||
@@ -61,3 +156,98 @@ func CheckFileExists(filePath string) bool {
|
|||||||
}
|
}
|
||||||
return !info.IsDir() && info.Size() > 0
|
return !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExistenceResult represents the result of checking if a file exists
|
||||||
|
type FileExistenceResult struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
TrackName string `json:"track_name,omitempty"`
|
||||||
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||||
|
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||||
|
// Same implementation as PC version for consistency
|
||||||
|
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||||
|
// Parse input JSON
|
||||||
|
var tracks []struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
|
// Build ISRC index from output directory (scan once)
|
||||||
|
isrcIdx := GetISRCIndex(outputDir)
|
||||||
|
|
||||||
|
// Check each track against the index (parallel)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, track := range tracks {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(resultIdx int, t struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
}) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
result := FileExistenceResult{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
Exists: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.ISRC != "" {
|
||||||
|
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
||||||
|
result.Exists = true
|
||||||
|
result.FilePath = filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[resultIdx] = result
|
||||||
|
}(i, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Return results as JSON
|
||||||
|
resultJSON, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(resultJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
||||||
|
// Call this when app starts or when entering album/playlist screen
|
||||||
|
func PreBuildISRCIndex(outputDir string) error {
|
||||||
|
if outputDir == "" {
|
||||||
|
return fmt.Errorf("output directory is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildISRCIndex(outputDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||||
|
// This avoids rebuilding the entire index
|
||||||
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
idx.Add(isrc, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := map[string]string{
|
||||||
"type": parsed.Type,
|
"type": parsed.Type,
|
||||||
"id": parsed.ID,
|
"id": parsed.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,18 +42,18 @@ func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
|||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
jsonBytes, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,18 +62,18 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|||||||
func SearchSpotify(query string, limit int) (string, error) {
|
func SearchSpotify(query string, limit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
results, err := client.SearchTracks(ctx, query, limit)
|
results, err := client.SearchTracks(ctx, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
jsonBytes, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,18 +82,18 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
jsonBytes, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +105,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(availability)
|
jsonBytes, err := json.Marshal(availability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ type DownloadRequest struct {
|
|||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,18 +143,34 @@ type DownloadResponse struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
// Actual quality info from the source
|
// Actual quality info from the source
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
// DownloadResult is a generic result type for all downloaders
|
// DownloadResult is a generic result type for all downloaders
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -165,25 +181,32 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace from string fields to prevent filename/path issues
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
if tidalErr == nil {
|
if tidalErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: tidalResult.FilePath,
|
FilePath: tidalResult.FilePath,
|
||||||
BitDepth: tidalResult.BitDepth,
|
BitDepth: tidalResult.BitDepth,
|
||||||
SampleRate: tidalResult.SampleRate,
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
ISRC: tidalResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
@@ -191,9 +214,16 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
if qobuzErr == nil {
|
if qobuzErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: qobuzResult.FilePath,
|
FilePath: qobuzResult.FilePath,
|
||||||
BitDepth: qobuzResult.BitDepth,
|
BitDepth: qobuzResult.BitDepth,
|
||||||
SampleRate: qobuzResult.SampleRate,
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
Title: qobuzResult.Title,
|
||||||
|
Artist: qobuzResult.Artist,
|
||||||
|
Album: qobuzResult.Album,
|
||||||
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
ISRC: qobuzResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -201,20 +231,27 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorResponse(err.Error())
|
return errorResponse(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
@@ -232,21 +269,28 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Service,
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
@@ -254,8 +298,15 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Service,
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
@@ -267,23 +318,23 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace from string fields to prevent filename/path issues
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
// Build service order starting with preferred service
|
||||||
allServices := []string{"qobuz", "tidal", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "qobuz"
|
preferredService = "tidal"
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
// Create ordered list: preferred first, then others
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
for _, s := range allServices {
|
for _, s := range allServices {
|
||||||
@@ -291,51 +342,78 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
services = append(services, s)
|
services = append(services, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services)
|
GoLog("[DownloadWithFallback] Service order: %v\n", services)
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
|
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
if tidalErr == nil {
|
if tidalErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: tidalResult.FilePath,
|
FilePath: tidalResult.FilePath,
|
||||||
BitDepth: tidalResult.BitDepth,
|
BitDepth: tidalResult.BitDepth,
|
||||||
SampleRate: tidalResult.SampleRate,
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
ISRC: tidalResult.ISRC,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
if qobuzErr == nil {
|
if qobuzErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: qobuzResult.FilePath,
|
FilePath: qobuzResult.FilePath,
|
||||||
BitDepth: qobuzResult.BitDepth,
|
BitDepth: qobuzResult.BitDepth,
|
||||||
SampleRate: qobuzResult.SampleRate,
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
Title: qobuzResult.Title,
|
||||||
|
Artist: qobuzResult.Artist,
|
||||||
|
Album: qobuzResult.Album,
|
||||||
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
ISRC: qobuzResult.ISRC,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
@@ -354,21 +432,28 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
@@ -376,14 +461,21 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +513,51 @@ func CleanupConnections() {
|
|||||||
CloseIdleConnections()
|
CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadFileMetadata reads metadata directly from a FLAC file
|
||||||
|
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
|
||||||
|
// This is useful for displaying accurate metadata in the UI without relying on cached data
|
||||||
|
func ReadFileMetadata(filePath string) (string, error) {
|
||||||
|
metadata, err := ReadMetadata(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get audio quality info
|
||||||
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
|
|
||||||
|
// Get duration from FLAC stream info
|
||||||
|
duration := 0
|
||||||
|
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"title": metadata.Title,
|
||||||
|
"artist": metadata.Artist,
|
||||||
|
"album": metadata.Album,
|
||||||
|
"album_artist": metadata.AlbumArtist,
|
||||||
|
"date": metadata.Date,
|
||||||
|
"track_number": metadata.TrackNumber,
|
||||||
|
"disc_number": metadata.DiscNumber,
|
||||||
|
"isrc": metadata.ISRC,
|
||||||
|
"lyrics": metadata.Lyrics,
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quality info if available
|
||||||
|
if qualityErr == nil {
|
||||||
|
result["bit_depth"] = quality.BitDepth
|
||||||
|
result["sample_rate"] = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetDownloadDirectory sets the default download directory
|
// SetDownloadDirectory sets the default download directory
|
||||||
func SetDownloadDirectory(path string) error {
|
func SetDownloadDirectory(path string) error {
|
||||||
return setDownloadDir(path)
|
return setDownloadDir(path)
|
||||||
@@ -429,27 +566,47 @@ func SetDownloadDirectory(path string) error {
|
|||||||
// CheckDuplicate checks if a file with the given ISRC exists
|
// CheckDuplicate checks if a file with the given ISRC exists
|
||||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"exists": exists,
|
"exists": exists,
|
||||||
"filepath": existingFile,
|
"filepath": existingFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
|
||||||
|
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
|
||||||
|
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
|
||||||
|
// Returns JSON array of results
|
||||||
|
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
||||||
|
return CheckFilesExistParallel(outputDir, tracksJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
|
||||||
|
// Call this when entering album/playlist screen for faster duplicate checking
|
||||||
|
func PreBuildDuplicateIndex(outputDir string) error {
|
||||||
|
return PreBuildISRCIndex(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
||||||
|
// Call this when files are deleted or moved
|
||||||
|
func InvalidateDuplicateIndex(outputDir string) {
|
||||||
|
InvalidateISRCCache(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildFilename builds a filename from template and metadata
|
// BuildFilename builds a filename from template and metadata
|
||||||
func BuildFilename(template string, metadataJSON string) (string, error) {
|
func BuildFilename(template string, metadataJSON string) (string, error) {
|
||||||
var metadata map[string]interface{}
|
var metadata map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(template, metadata)
|
filename := buildFilenameFromTemplate(template, metadata)
|
||||||
return filename, nil
|
return filename, nil
|
||||||
}
|
}
|
||||||
@@ -483,7 +640,7 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
||||||
// First tries to extract from file, then falls back to fetching from internet
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
||||||
// Try to extract from file first (much faster)
|
// Try to extract from file first (much faster)
|
||||||
@@ -501,7 +658,8 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyricsData)
|
// Convert to LRC format with metadata headers (like PC version)
|
||||||
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,18 +736,18 @@ func ClearTrackIDCache() {
|
|||||||
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := GetDeezerClient()
|
client := GetDeezerClient()
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
jsonBytes, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,11 +757,11 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error)
|
|||||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := GetDeezerClient()
|
client := GetDeezerClient()
|
||||||
var data interface{}
|
var data interface{}
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch resourceType {
|
switch resourceType {
|
||||||
case "track":
|
case "track":
|
||||||
data, err = client.GetTrack(ctx, resourceID)
|
data, err = client.GetTrack(ctx, resourceID)
|
||||||
@@ -616,16 +774,16 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
|||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
jsonBytes, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,17 +793,17 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := map[string]string{
|
||||||
"type": resourceType,
|
"type": resourceType,
|
||||||
"id": resourceID,
|
"id": resourceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,18 +811,18 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := GetDeezerClient()
|
client := GetDeezerClient()
|
||||||
track, err := client.SearchByISRC(ctx, isrc)
|
track, err := client.SearchByISRC(ctx, isrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(track)
|
jsonBytes, err := json.Marshal(track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,52 +832,52 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
|
|
||||||
// For tracks, we can use SongLink to get Deezer ID
|
// For tracks, we can use SongLink to get Deezer ID
|
||||||
if resourceType == "track" {
|
if resourceType == "track" {
|
||||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata from Deezer
|
// Fetch metadata from Deezer
|
||||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(trackResp)
|
jsonBytes, err := json.Marshal(trackResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For albums, SongLink also provides mapping
|
// For albums, SongLink also provides mapping
|
||||||
if resourceType == "album" {
|
if resourceType == "album" {
|
||||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch album metadata from Deezer
|
// Fetch album metadata from Deezer
|
||||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(albumResp)
|
jsonBytes, err := json.Marshal(albumResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For artists/playlists, SongLink doesn't provide direct mapping
|
// For artists/playlists, SongLink doesn't provide direct mapping
|
||||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
}
|
}
|
||||||
@@ -728,7 +886,7 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try Spotify first
|
// Try Spotify first
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
@@ -739,39 +897,127 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
}
|
}
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a rate limit error
|
// Check if it's a rate limit error
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||||
// Not a rate limit error, return original error
|
// Not a rate limit error, return original error
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limited - try Deezer fallback for tracks and albums
|
// Rate limited - try Deezer fallback for tracks and albums
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
// Convert to Deezer
|
// Convert to Deezer
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist and playlist not supported for fallback
|
// Artist and playlist not supported for fallback
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SONGLINK DEEZER SUPPORT ====================
|
||||||
|
|
||||||
|
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
|
||||||
|
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
|
||||||
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(availability)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailabilityByPlatformID checks track availability using any platform as source
|
||||||
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube"
|
||||||
|
// entityType: "song" or "album"
|
||||||
|
// entityID: the ID on that platform
|
||||||
|
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(availability)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID
|
||||||
|
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL
|
||||||
|
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL
|
||||||
|
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
return client.GetAmazonURLFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
|
// Determine error type based on message
|
||||||
|
errorType := "unknown"
|
||||||
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
|
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
|
errorType = "isp_blocked"
|
||||||
|
} else if strings.Contains(lowerMsg, "permission") ||
|
||||||
|
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||||
|
strings.Contains(lowerMsg, "access denied") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create file") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create directory") {
|
||||||
|
errorType = "permission"
|
||||||
|
} else if strings.Contains(lowerMsg, "not found") ||
|
||||||
|
strings.Contains(lowerMsg, "not available") ||
|
||||||
|
strings.Contains(lowerMsg, "no results") ||
|
||||||
|
strings.Contains(lowerMsg, "track not found") ||
|
||||||
|
strings.Contains(lowerMsg, "all services failed") {
|
||||||
|
errorType = "not_found"
|
||||||
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||||
|
strings.Contains(lowerMsg, "429") ||
|
||||||
|
strings.Contains(lowerMsg, "too many requests") {
|
||||||
|
errorType = "rate_limit"
|
||||||
|
} else if strings.Contains(lowerMsg, "network") ||
|
||||||
|
strings.Contains(lowerMsg, "connection") ||
|
||||||
|
strings.Contains(lowerMsg, "timeout") ||
|
||||||
|
strings.Contains(lowerMsg, "dial") {
|
||||||
|
errorType = "network"
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: msg,
|
Error: msg,
|
||||||
|
ErrorType: errorType,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
|
|||||||
@@ -1,36 +1,75 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP utility functions for consistent request handling across all downloaders
|
// HTTP utility functions for consistent request handling across all downloaders
|
||||||
|
|
||||||
// User-Agent pool for Android Chrome browsers
|
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||||
var userAgentTemplates = []string{
|
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
func getRandomUserAgent() string {
|
||||||
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||||
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
chromeVersion,
|
||||||
|
chromeBuild,
|
||||||
|
chromePatch,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format)
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
func getRandomUserAgent() string {
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
|
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
|
||||||
|
|
||||||
androidVersion := rand.Intn(5) + 10 // Android 10-14
|
return fmt.Sprintf(
|
||||||
deviceModel := rand.Intn(900) + 100 // Random model number
|
"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",
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
macMajor,
|
||||||
chromeBuild := rand.Intn(5000) + 5000
|
macMinor,
|
||||||
chromePatch := rand.Intn(200) + 100
|
webkitMajor,
|
||||||
|
webkitMinor,
|
||||||
|
chromeMajor,
|
||||||
|
chromeBuild,
|
||||||
|
chromePatch,
|
||||||
|
safariMajor,
|
||||||
|
safariMinor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch)
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
|
func getRandomDesktopUserAgent() string {
|
||||||
|
if rand.Intn(2) == 0 {
|
||||||
|
return getRandomUserAgent() // Windows
|
||||||
|
}
|
||||||
|
return getRandomMacUserAgent() // Mac
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default timeout values
|
// Default timeout values
|
||||||
@@ -100,9 +139,15 @@ func CloseIdleConnections() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||||
|
// Also checks for ISP blocking on errors
|
||||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
return client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Check for ISP blocking
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryConfig holds configuration for retry logic
|
// RetryConfig holds configuration for retry logic
|
||||||
@@ -125,9 +170,11 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
|
|
||||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||||
|
// Also detects and logs ISP blocking
|
||||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delay := config.InitialDelay
|
delay := config.InitialDelay
|
||||||
|
requestURL := req.URL.String()
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
// Clone request for retry (body needs to be re-readable)
|
// Clone request for retry (body needs to be re-readable)
|
||||||
@@ -137,7 +184,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
resp, err := client.Do(reqCopy)
|
resp, err := client.Do(reqCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
// Check for ISP blocking on network errors
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||||
|
// Don't retry if ISP blocking is detected - it won't help
|
||||||
|
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||||
|
}
|
||||||
|
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||||
|
attempt+1, config.MaxRetries+1, err, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -158,17 +214,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
}
|
}
|
||||||
lastErr = fmt.Errorf("rate limited (429)")
|
lastErr = fmt.Errorf("rate limited (429)")
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for ISP blocking via HTTP status codes
|
||||||
|
// Some ISPs return 403 or 451 when blocking content
|
||||||
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
|
// Check if response looks like ISP blocking page
|
||||||
|
ispBlockingIndicators := []string{
|
||||||
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indicator := range ispBlockingIndicators {
|
||||||
|
if strings.Contains(bodyStr, indicator) {
|
||||||
|
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||||
|
LogError("HTTP", "Domain: %s", req.URL.Host)
|
||||||
|
LogError("HTTP", "Response contains: %s", indicator)
|
||||||
|
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server errors (5xx) - retry
|
// Server errors (5xx) - retry
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -262,3 +344,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
|||||||
}
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISPBlockingError represents an error caused by ISP blocking
|
||||||
|
type ISPBlockingError struct {
|
||||||
|
Domain string
|
||||||
|
Reason string
|
||||||
|
OriginalErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ISPBlockingError) Error() string {
|
||||||
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
||||||
|
// Returns the ISPBlockingError if detected, nil otherwise
|
||||||
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domain from URL
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// Check for DNS resolution failure (common ISP blocking method)
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for connection refused (ISP firewall blocking)
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
if opErr.Op == "dial" {
|
||||||
|
// Check for specific syscall errors
|
||||||
|
var syscallErr syscall.Errno
|
||||||
|
if errors.As(opErr.Err, &syscallErr) {
|
||||||
|
switch syscallErr {
|
||||||
|
case syscall.ECONNREFUSED:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ECONNRESET:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ETIMEDOUT:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection timed out - ISP may be blocking access",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ENETUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Network unreachable - ISP may be blocking route",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.EHOSTUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Host unreachable - ISP may be blocking destination",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
||||||
|
var tlsErr *tls.RecordHeaderError
|
||||||
|
if errors.As(err, &tlsErr) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check error message patterns for common ISP blocking indicators
|
||||||
|
blockingPatterns := []struct {
|
||||||
|
pattern string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||||
|
{"connection refused", "Connection refused - port may be blocked"},
|
||||||
|
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||||
|
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||||
|
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||||
|
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||||
|
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||||
|
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||||
|
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bp := range blockingPatterns {
|
||||||
|
if strings.Contains(errStr, bp.pattern) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: bp.reason,
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
||||||
|
// Returns true if ISP blocking was detected
|
||||||
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
|
if ispErr != nil {
|
||||||
|
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
|
||||||
|
LogError(tag, "Domain: %s", ispErr.Domain)
|
||||||
|
LogError(tag, "Reason: %s", ispErr.Reason)
|
||||||
|
LogError(tag, "Original error: %v", ispErr.OriginalErr)
|
||||||
|
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomain extracts the domain from a URL string
|
||||||
|
func extractDomain(rawURL string) string {
|
||||||
|
if rawURL == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
// Try to extract domain manually
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||||
|
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||||
|
return rawURL[:idx]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "" {
|
||||||
|
return parsed.Host
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
||||||
|
// If ISP blocking is detected, returns a more descriptive error
|
||||||
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry represents a single log entry
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
||||||
|
type LogBuffer struct {
|
||||||
|
entries []LogEntry
|
||||||
|
maxSize int
|
||||||
|
mu sync.RWMutex
|
||||||
|
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalLogBuffer *LogBuffer
|
||||||
|
logBufferOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetLogBuffer returns the singleton log buffer instance
|
||||||
|
func GetLogBuffer() *LogBuffer {
|
||||||
|
logBufferOnce.Do(func() {
|
||||||
|
globalLogBuffer = &LogBuffer{
|
||||||
|
entries: make([]LogEntry, 0, 500),
|
||||||
|
maxSize: 500,
|
||||||
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalLogBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging
|
||||||
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.loggingEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLoggingEnabled returns whether logging is enabled
|
||||||
|
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
return lb.loggingEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a log entry to the buffer
|
||||||
|
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
|
||||||
|
// Skip if logging is disabled (except for errors which are always logged)
|
||||||
|
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := LogEntry{
|
||||||
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
|
Level: level,
|
||||||
|
Tag: tag,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lb.entries) >= lb.maxSize {
|
||||||
|
// Remove oldest entry
|
||||||
|
lb.entries = lb.entries[1:]
|
||||||
|
}
|
||||||
|
lb.entries = append(lb.entries, entry)
|
||||||
|
|
||||||
|
// Also print to logcat for debugging
|
||||||
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all log entries as JSON
|
||||||
|
func (lb *LogBuffer) GetAll() string {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(lb.entries)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSince returns log entries since the given index (internal use)
|
||||||
|
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(lb.entries) {
|
||||||
|
return []LogEntry{}, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := lb.entries[index:]
|
||||||
|
return entries, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears all log entries
|
||||||
|
func (lb *LogBuffer) Clear() {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.entries = lb.entries[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of log entries
|
||||||
|
func (lb *LogBuffer) Count() int {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
return len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for logging with different levels
|
||||||
|
func LogDebug(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogInfo(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogWarn(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogError(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||||
|
// It parses the tag from the format string if it starts with [Tag]
|
||||||
|
func GoLog(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
message = strings.TrimSuffix(message, "\n")
|
||||||
|
|
||||||
|
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||||
|
tag := "Go"
|
||||||
|
level := "INFO"
|
||||||
|
|
||||||
|
if strings.HasPrefix(message, "[") {
|
||||||
|
endBracket := strings.Index(message, "]")
|
||||||
|
if endBracket > 1 {
|
||||||
|
tag = message[1:endBracket]
|
||||||
|
message = strings.TrimSpace(message[endBracket+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine level from message content
|
||||||
|
msgLower := strings.ToLower(message)
|
||||||
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||||
|
level = "ERROR"
|
||||||
|
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||||
|
level = "WARN"
|
||||||
|
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||||
|
level = "INFO"
|
||||||
|
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||||
|
level = "DEBUG"
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLogBuffer().Add(level, tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported functions for Flutter
|
||||||
|
|
||||||
|
// GetLogs returns all logs as JSON array
|
||||||
|
func GetLogs() string {
|
||||||
|
return GetLogBuffer().GetAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogsSince returns logs since the given index
|
||||||
|
// Returns JSON: {"logs": [...], "next_index": N}
|
||||||
|
func GetLogsSince(index int) string {
|
||||||
|
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||||
|
logsJson, _ := json.Marshal(entries)
|
||||||
|
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLogs clears all logs
|
||||||
|
func ClearLogs() {
|
||||||
|
GetLogBuffer().Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogCount returns the number of log entries
|
||||||
|
func GetLogCount() int {
|
||||||
|
return GetLogBuffer().Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging from Flutter
|
||||||
|
func SetLoggingEnabled(enabled bool) {
|
||||||
|
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
@@ -248,6 +248,8 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||||
|
// Use convertToLRCWithMetadata for full LRC with headers
|
||||||
func convertToLRC(lyrics *LyricsResponse) string {
|
func convertToLRC(lyrics *LyricsResponse) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -272,6 +274,45 @@ func convertToLRC(lyrics *LyricsResponse) string {
|
|||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||||
|
// Includes [ti:], [ar:], [by:] headers
|
||||||
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
// Add metadata headers
|
||||||
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
|
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
// Add lyrics lines
|
||||||
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
|
builder.WriteString(timestamp)
|
||||||
|
builder.WriteString(line.Words)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.WriteString(line.Words)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
func simplifyTrackName(name string) string {
|
func simplifyTrackName(name string) string {
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
`\s*\(feat\..*?\)`,
|
`\s*\(feat\..*?\)`,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -66,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -162,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -170,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -257,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
}
|
}
|
||||||
|
// Also try lowercase variant (some encoders use lowercase)
|
||||||
|
if metadata.TrackNumber == 0 {
|
||||||
|
trackNum = getComment(cmt, "TRACK")
|
||||||
|
if trackNum != "" {
|
||||||
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
discNum := getComment(cmt, "DISCNUMBER")
|
discNum := getComment(cmt, "DISCNUMBER")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
|
// Also try DISC variant
|
||||||
|
if metadata.DiscNumber == 0 {
|
||||||
|
discNum = getComment(cmt, "DISC")
|
||||||
|
if discNum != "" {
|
||||||
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try DATE variants
|
||||||
|
if metadata.Date == "" {
|
||||||
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -291,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||||
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" {
|
if len(comment) > len(key) {
|
||||||
return comment[len(key)+1:]
|
// Case-insensitive comparison for Vorbis comments
|
||||||
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
|
if commentUpper == keyUpper {
|
||||||
|
return comment[len(key)+1:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -356,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try LYRICS tag first
|
// Try LYRICS tag first
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to UNSYNCEDLYRICS
|
// Fallback to UNSYNCEDLYRICS
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
@@ -376,12 +400,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
|
|
||||||
// AudioQuality represents audio quality info from a FLAC file
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
TotalSamples int64 `json:"total_samples"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||||
|
// For M4A files, it delegates to GetM4AQuality
|
||||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,45 +415,408 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read FLAC marker (4 bytes: "fLaC")
|
// Read first 4 bytes to detect file type
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
if string(marker) != "fLaC" {
|
|
||||||
return AudioQuality{}, fmt.Errorf("not a FLAC file")
|
// Check if it's a FLAC file
|
||||||
|
if string(marker) == "fLaC" {
|
||||||
|
// Continue reading FLAC metadata
|
||||||
|
// Read metadata block header (4 bytes)
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if _, err := file.Read(header); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockType := header[0] & 0x7F
|
||||||
|
if blockType != 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read STREAMINFO block (34 bytes minimum)
|
||||||
|
streamInfo := make([]byte, 34)
|
||||||
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse sample rate (20 bits starting at byte 10)
|
||||||
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
TotalSamples: totalSamples,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata block header (4 bytes)
|
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
||||||
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
|
// First 4 bytes are size, next 4 should be "ftyp"
|
||||||
// Bytes 1-3: block length (24-bit big-endian)
|
file.Seek(0, 0) // Reset to beginning
|
||||||
header := make([]byte, 4)
|
header8 := make([]byte, 8)
|
||||||
if _, err := file.Read(header); err != nil {
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blockType := header[0] & 0x7F
|
if string(header8[4:8]) == "ftyp" {
|
||||||
if blockType != 0 {
|
// It's an M4A/MP4 file, use M4A quality reader
|
||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||||
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
// Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits)
|
}
|
||||||
streamInfo := make([]byte, 34)
|
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
// ========================================
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
}
|
// ========================================
|
||||||
|
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
|
// This is a simplified implementation that writes metadata to the file
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
|
// Read the entire file
|
||||||
// Parse bits per sample (5 bits)
|
data, err := os.ReadFile(filePath)
|
||||||
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
|
if err != nil {
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
return AudioQuality{
|
|
||||||
BitDepth: bitsPerSample,
|
// Find moov atom position
|
||||||
SampleRate: sampleRate,
|
moovPos := findAtom(data, "moov", 0)
|
||||||
}, nil
|
if moovPos < 0 {
|
||||||
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find udta atom inside moov, or create one
|
||||||
|
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | 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])
|
||||||
|
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])
|
||||||
|
newData = append(newData, data[:metaPos]...)
|
||||||
|
newData = append(newData, metaAtom...)
|
||||||
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
|
} else {
|
||||||
|
// Add meta atom to udta
|
||||||
|
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||||
|
newUdtaSize := 8 + len(newUdtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(newUdtaSize >> 24)
|
||||||
|
newUdta[1] = byte(newUdtaSize >> 16)
|
||||||
|
newUdta[2] = byte(newUdtaSize >> 8)
|
||||||
|
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:]...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new udta with meta
|
||||||
|
udtaContent := metaAtom
|
||||||
|
udtaSize := 8 + len(udtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(udtaSize >> 24)
|
||||||
|
newUdta[1] = byte(udtaSize >> 16)
|
||||||
|
newUdta[2] = byte(udtaSize >> 8)
|
||||||
|
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)
|
||||||
|
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||||
|
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||||
|
newData[moovPos+3] = byte(newMoovSize)
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
if size < 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
atomName := string(data[i+4 : i+8])
|
||||||
|
if atomName == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
|
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)
|
||||||
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
|
ilstAtom[1] = byte(ilstSize >> 16)
|
||||||
|
ilstAtom[2] = byte(ilstSize >> 8)
|
||||||
|
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
|
||||||
|
'h', 'd', 'l', 'r',
|
||||||
|
0, 0, 0, 0, // version + flags
|
||||||
|
0, 0, 0, 0, // predefined
|
||||||
|
'm', 'd', 'i', 'r', // handler type
|
||||||
|
'a', 'p', 'p', 'l', // manufacturer
|
||||||
|
0, 0, 0, 0, // component flags
|
||||||
|
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)
|
||||||
|
metaAtom[1] = byte(metaSize >> 16)
|
||||||
|
metaAtom[2] = byte(metaSize >> 8)
|
||||||
|
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)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
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)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte(name)...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrackNumberAtom builds trkn atom
|
||||||
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
|
// data atom with track number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 24, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(track >> 8), byte(track), // track number
|
||||||
|
byte(total >> 8), byte(total), // total tracks
|
||||||
|
0, 0, // padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("trkn")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiscNumberAtom builds disk atom
|
||||||
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
|
// data atom with disc number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 22, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(disc >> 8), byte(disc), // disc number
|
||||||
|
byte(total >> 8), byte(total), // total discs
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("disk")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCoverAtom builds covr atom with image data
|
||||||
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
|
// Detect image type (JPEG = 13, PNG = 14)
|
||||||
|
imageType := byte(13) // default JPEG
|
||||||
|
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)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
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)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("covr")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetM4AQuality reads audio quality from M4A file
|
||||||
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
||||||
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for mp4a or alac atom which contains audio info
|
||||||
|
// This is a simplified search - real implementation would traverse the atom tree
|
||||||
|
for i := moovPos; i < len(data)-20; i++ {
|
||||||
|
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||||
|
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
||||||
|
if i+24 < len(data) {
|
||||||
|
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||||
|
// For AAC, bit depth is typically 16
|
||||||
|
bitDepth := 16
|
||||||
|
if string(data[i:i+4]) == "alac" {
|
||||||
|
// ALAC can have higher bit depth, check esds or alac specific data
|
||||||
|
bitDepth = 24 // Assume 24-bit for ALAC
|
||||||
|
}
|
||||||
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ func FetchCoverAndLyricsParallel(
|
|||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
result.LyricsLRC = convertToLRC(lyrics)
|
// Use LRC with metadata headers (like PC version)
|
||||||
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
} else {
|
} else {
|
||||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
// DownloadProgress represents current download progress
|
||||||
@@ -23,6 +24,7 @@ type ItemProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
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"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,20 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
||||||
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
item.SpeedMBps = speedMBps
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CompleteItemProgress marks an item as complete
|
// CompleteItemProgress marks an item as complete
|
||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
@@ -199,22 +215,29 @@ type ItemProgressWriter struct {
|
|||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||||
|
startTime time.Time // Track start time for speed calculation
|
||||||
|
lastTime time.Time // Track last update time for speed calculation
|
||||||
|
lastBytes int64 // Track bytes at last speed calculation
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
|
now := time.Now()
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
writer: w,
|
writer: w,
|
||||||
itemID: itemID,
|
itemID: itemID,
|
||||||
current: 0,
|
current: 0,
|
||||||
lastReported: 0,
|
lastReported: 0,
|
||||||
|
startTime: now,
|
||||||
|
lastTime: now,
|
||||||
|
lastBytes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer with threshold-based progress updates
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,8 +248,19 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
// Update progress when we've received at least 64KB since last update
|
// Update progress when we've received at least 64KB since last update
|
||||||
// Also update on first write to show download has started
|
// Also update on first write to show download has started
|
||||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
// Calculate speed (MB/s) based on bytes received since last update
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
|
var speedMBps float64
|
||||||
|
if elapsed > 0 {
|
||||||
|
bytesInInterval := pw.current - pw.lastBytes
|
||||||
|
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||||
pw.lastReported = pw.current
|
pw.lastReported = pw.current
|
||||||
|
pw.lastTime = now
|
||||||
|
pw.lastBytes = pw.current
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,49 +52,224 @@ type QobuzTrack struct {
|
|||||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
// Check if one contains the other
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
if expectedFirst == foundFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
// assume they're the same artist with different transliteration
|
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||||
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||||
foundASCII := qobuzIsASCIIString(foundArtist)
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedLatin != foundLatin {
|
||||||
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qobuzTitlesMatch checks if track titles are similar enough
|
||||||
|
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||||
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
|
||||||
|
cleanExpected := qobuzCleanTitle(normExpected)
|
||||||
|
cleanFound := qobuzCleanTitle(normFound)
|
||||||
|
|
||||||
|
if cleanExpected == cleanFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cleaned versions contain each other
|
||||||
|
if cleanExpected != "" && cleanFound != "" {
|
||||||
|
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract core title (before any parentheses/brackets)
|
||||||
|
coreExpected := qobuzExtractCoreTitle(normExpected)
|
||||||
|
coreFound := qobuzExtractCoreTitle(normFound)
|
||||||
|
|
||||||
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
|
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||||
|
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||||
|
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||||
|
if expectedLatin != foundLatin {
|
||||||
|
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
|
||||||
|
func qobuzExtractCoreTitle(title string) string {
|
||||||
|
// Find first occurrence of ( or [
|
||||||
|
parenIdx := strings.Index(title, "(")
|
||||||
|
bracketIdx := strings.Index(title, "[")
|
||||||
|
dashIdx := strings.Index(title, " - ")
|
||||||
|
|
||||||
|
cutIdx := len(title)
|
||||||
|
if parenIdx > 0 && parenIdx < cutIdx {
|
||||||
|
cutIdx = parenIdx
|
||||||
|
}
|
||||||
|
if bracketIdx > 0 && bracketIdx < cutIdx {
|
||||||
|
cutIdx = bracketIdx
|
||||||
|
}
|
||||||
|
if dashIdx > 0 && dashIdx < cutIdx {
|
||||||
|
cutIdx = dashIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(title[:cutIdx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzCleanTitle removes common suffixes from track titles for comparison
|
||||||
|
func qobuzCleanTitle(title string) string {
|
||||||
|
cleaned := title
|
||||||
|
|
||||||
|
// Remove content in parentheses/brackets that are version indicators
|
||||||
|
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
||||||
|
versionPatterns := []string{
|
||||||
|
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||||
|
"album version", "radio edit", "original mix", "extended",
|
||||||
|
"club mix", "remix", "live", "acoustic", "demo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove parenthetical content if it contains version indicators
|
||||||
|
for {
|
||||||
|
startParen := strings.LastIndex(cleaned, "(")
|
||||||
|
endParen := strings.LastIndex(cleaned, ")")
|
||||||
|
if startParen >= 0 && endParen > startParen {
|
||||||
|
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same for brackets
|
||||||
|
for {
|
||||||
|
startBracket := strings.LastIndex(cleaned, "[")
|
||||||
|
endBracket := strings.LastIndex(cleaned, "]")
|
||||||
|
if startBracket >= 0 && endBracket > startBracket {
|
||||||
|
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing " - version" patterns
|
||||||
|
dashPatterns := []string{
|
||||||
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||||
|
" - live", " - acoustic", " - demo", " - remix",
|
||||||
|
}
|
||||||
|
for _, pattern := range dashPatterns {
|
||||||
|
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
||||||
|
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(cleaned, " ") {
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsLatinScript checks if a string is primarily Latin script
|
||||||
|
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||||
|
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||||
|
func qobuzIsLatinScript(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
// Skip common punctuation and numbers
|
||||||
|
if r < 128 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||||
|
// Latin Extended-B: U+0180 to U+024F
|
||||||
|
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||||
|
// Latin Extended-C/D/E: various ranges
|
||||||
|
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||||
|
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||||
|
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// CJK ranges - definitely different script
|
||||||
|
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||||
|
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||||
|
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||||
|
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||||
|
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
func qobuzIsASCIIString(s string) bool {
|
func qobuzIsASCIIString(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -105,6 +280,16 @@ func qobuzIsASCIIString(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// containsQueryQobuz checks if a query already exists in the list
|
||||||
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
|
for _, q := range queries {
|
||||||
|
if q == query {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
qobuzDownloaderOnce.Do(func() {
|
qobuzDownloaderOnce.Do(func() {
|
||||||
@@ -122,8 +307,8 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
|||||||
// Same APIs as PC version (referensi/backend/qobuz.go)
|
// Same APIs as PC version (referensi/backend/qobuz.go)
|
||||||
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
||||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -184,6 +369,8 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
@@ -211,6 +398,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||||
|
|
||||||
// Find ISRC matches
|
// Find ISRC matches
|
||||||
var isrcMatches []*QobuzTrack
|
var isrcMatches []*QobuzTrack
|
||||||
for i := range result.Tracks.Items {
|
for i := range result.Tracks.Items {
|
||||||
@@ -219,6 +408,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||||
|
|
||||||
if len(isrcMatches) > 0 {
|
if len(isrcMatches) > 0 {
|
||||||
// Verify duration if provided
|
// Verify duration if provided
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
@@ -228,27 +419,27 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 30 seconds tolerance
|
// Allow 10 seconds tolerance
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 10 {
|
||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC matches but duration doesn't
|
// ISRC matches but duration doesn't
|
||||||
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
expectedDurationSec, isrcMatches[0].Duration)
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, return first match
|
// No duration to verify, return first match
|
||||||
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,10 +461,12 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||||
|
// Also includes title verification to prevent wrong song downloads
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies (same as Tidal/PC version)
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name
|
// Strategy 1: Artist + Track name
|
||||||
@@ -286,10 +479,54 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Romaji versions if Japanese detected
|
||||||
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
|
// Convert to romaji (hiragana/katakana only, kanji stays)
|
||||||
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean and remove ALL non-ASCII characters (including kanji)
|
||||||
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
|
// Artist + Track romaji (cleaned to ASCII only)
|
||||||
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
|
if !containsQueryQobuz(queries, romajiQuery) {
|
||||||
|
queries = append(queries, romajiQuery)
|
||||||
|
GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track romaji only (cleaned)
|
||||||
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
|
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
||||||
|
queries = append(queries, cleanRomajiTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: Artist only as last resort
|
||||||
|
if artistName != "" {
|
||||||
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
|
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
||||||
|
queries = append(queries, artistOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var allTracks []QobuzTrack
|
var allTracks []QobuzTrack
|
||||||
|
searchedQueries := make(map[string]bool)
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
cleanQuery := strings.TrimSpace(query)
|
||||||
|
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
searchedQueries[cleanQuery] = true
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -298,6 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +556,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
|
GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||||
allTracks = append(allTracks, result.Tracks.Items...)
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,16 +565,35 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by title match first (NEW - like Tidal)
|
||||||
|
var titleMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if qobuzTitlesMatch(trackName, track.Title) {
|
||||||
|
titleMatches = append(titleMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
||||||
|
|
||||||
|
// If no title matches, log warning but continue with all tracks
|
||||||
|
tracksToCheck := titleMatches
|
||||||
|
if len(titleMatches) == 0 {
|
||||||
|
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
||||||
|
for i := range allTracks {
|
||||||
|
tracksToCheck = append(tracksToCheck, &allTracks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If duration verification is requested
|
// If duration verification is requested
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
var durationMatches []*QobuzTrack
|
var durationMatches []*QobuzTrack
|
||||||
for i := range allTracks {
|
for _, track := range tracksToCheck {
|
||||||
track := &allTracks[i]
|
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 10 {
|
||||||
durationMatches = append(durationMatches, track)
|
durationMatches = append(durationMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,24 +602,36 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
// Return best quality among duration matches
|
// Return best quality among duration matches
|
||||||
for _, track := range durationMatches {
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||||
|
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||||
return durationMatches[0], nil
|
return durationMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration match found
|
// No duration match found
|
||||||
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration verification, return best quality
|
// No duration verification, return best quality from title matches
|
||||||
for i := range allTracks {
|
for _, track := range tracksToCheck {
|
||||||
track := &allTracks[i]
|
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &allTracks[0], nil
|
|
||||||
|
if len(tracksToCheck) > 0 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||||
|
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||||
|
return tracksToCheck[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -380,7 +650,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
|||||||
// The apiURL already includes the path, just append trackID and quality
|
// The apiURL already includes the path, just append trackID and quality
|
||||||
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
||||||
|
|
||||||
fmt.Printf("[Qobuz] Trying: %s\n", reqURL)
|
GoLog("[Qobuz] Trying: %s\n", reqURL)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -425,7 +695,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.URL != "" {
|
if result.URL != "" {
|
||||||
fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL)
|
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
|
||||||
return apiURL, result.URL, nil
|
return apiURL, result.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,9 +796,16 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
|
|
||||||
// QobuzDownloadResult contains download result with quality info
|
// QobuzDownloadResult contains download result with quality info
|
||||||
type QobuzDownloadResult struct {
|
type QobuzDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
@@ -549,11 +826,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
// OPTIMIZATION: Check cache first for track ID
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err)
|
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -561,21 +838,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
|
|
||||||
// Strategy 1: Search by ISRC with duration verification
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
|
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
// Verify artist
|
// Verify artist AND title
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil {
|
||||||
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
req.ArtistName, track.Performer.Name)
|
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
track = nil
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
|
||||||
|
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.TrackName, track.Title)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata with duration verification
|
// Strategy 2: Search by metadata with duration verification (includes title verification)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
// Verify artist
|
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, track.Performer.Name)
|
req.ArtistName, track.Performer.Name)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
@@ -590,7 +874,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log match found and cache the track ID
|
// Log match found and cache the track ID
|
||||||
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
@@ -624,12 +908,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
case "HI_RES_LOSSLESS":
|
case "HI_RES_LOSSLESS":
|
||||||
qobuzQuality = "27" // 24-bit 192kHz
|
qobuzQuality = "27" // 24-bit 192kHz
|
||||||
}
|
}
|
||||||
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
// Get actual quality from track metadata
|
// Get actual quality from track metadata
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
@@ -668,23 +952,30 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using parallel-fetched cover data
|
// Embed metadata using parallel-fetched cover data
|
||||||
|
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
|
||||||
|
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
|
||||||
|
albumName := track.Album.Title
|
||||||
|
if req.AlbumName != "" {
|
||||||
|
albumName = req.AlbumName
|
||||||
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: track.Title,
|
||||||
Artist: req.ArtistName,
|
Artist: track.Performer.Name,
|
||||||
Album: req.AlbumName,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||||
Date: req.ReleaseDate,
|
Date: track.Album.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||||
ISRC: req.ISRC,
|
ISRC: track.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
@@ -693,9 +984,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||||
}
|
}
|
||||||
@@ -703,9 +994,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: actualBitDepth,
|
BitDepth: actualBitDepth,
|
||||||
SampleRate: actualSampleRate,
|
SampleRate: actualSampleRate,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: track.Performer.Name,
|
||||||
|
Album: track.Album.Title,
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||||
|
ISRC: track.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hiragana to Romaji mapping
|
||||||
|
var hiraganaToRomaji = map[rune]string{
|
||||||
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
|
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||||
|
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||||
|
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||||
|
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||||
|
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||||
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
|
'っ': "", // Double consonant marker
|
||||||
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Katakana to Romaji mapping
|
||||||
|
var katakanaToRomaji = map[rune]string{
|
||||||
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
|
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||||
|
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||||
|
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||||
|
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||||
|
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||||
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
|
'ッ': "", // Double consonant marker
|
||||||
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
|
// Extended katakana
|
||||||
|
'ー': "", // Long vowel mark
|
||||||
|
'ヴ': "vu",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combination mappings for きゃ, しゃ, etc.
|
||||||
|
var combinationHiragana = map[string]string{
|
||||||
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
|
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||||
|
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||||
|
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||||
|
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||||
|
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||||
|
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||||
|
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||||
|
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||||
|
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinationKatakana = map[string]string{
|
||||||
|
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||||
|
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||||
|
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||||
|
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||||
|
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||||
|
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||||
|
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||||
|
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||||
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
|
// Extended combinations
|
||||||
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsJapanese checks if a string contains Japanese characters
|
||||||
|
func ContainsJapanese(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHiragana(r rune) bool {
|
||||||
|
return r >= 0x3040 && r <= 0x309F
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKatakana(r rune) bool {
|
||||||
|
return r >= 0x30A0 && r <= 0x30FF
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKanji(r rune) bool {
|
||||||
|
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
|
}
|
||||||
|
|
||||||
|
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||||
|
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||||
|
func JapaneseToRomaji(text string) string {
|
||||||
|
if !ContainsJapanese(text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
runes := []rune(text)
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(runes) {
|
||||||
|
// Check for っ/ッ (double consonant)
|
||||||
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
|
nextRomaji := ""
|
||||||
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
}
|
||||||
|
if len(nextRomaji) > 0 {
|
||||||
|
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for two-character combinations
|
||||||
|
if i < len(runes)-1 {
|
||||||
|
combo := string(runes[i : i+2])
|
||||||
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if romaji, ok := combinationKatakana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single character conversion
|
||||||
|
r := runes[i]
|
||||||
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if isKanji(r) {
|
||||||
|
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
// Keep other characters (punctuation, spaces, etc.)
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSearchQuery creates a search query from track name and artist
|
||||||
|
// Converts Japanese to romaji if present
|
||||||
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
|
// Convert Japanese to romaji
|
||||||
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean up the query - remove special characters that might interfere with search
|
||||||
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSearchQuery removes special characters that might interfere with search
|
||||||
|
func cleanSearchQuery(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||||
|
// This is useful for creating search queries that work better with Tidal's search
|
||||||
|
func CleanToASCII(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == ',' || r == '.' {
|
||||||
|
// Convert punctuation to space
|
||||||
|
result.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up multiple spaces
|
||||||
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
// CheckTrackAvailability checks track availability on streaming platforms
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
||||||
|
if spotifyTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("spotify track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
// Use global rate limiter - blocks until request is allowed
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -71,8 +76,18 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
@@ -114,7 +129,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC
|
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
@@ -282,3 +297,248 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
|||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Deezer ID Support - Query SongLink using Deezer as source
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
||||||
|
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
if deezerTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build Deezer URL
|
||||||
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
|
// Build API URL using Deezer URL as source
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
EntitiesByUniqueId map[string]struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
} `json:"entitiesByUniqueId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
Deezer: true,
|
||||||
|
DeezerID: deezerTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
// Extract Spotify ID from URL
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer URL
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||||
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||||
|
// entityType: "song" or "album"
|
||||||
|
// entityID: the ID on that platform
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entityID string) (*TrackAvailability, error) {
|
||||||
|
if entityID == "" {
|
||||||
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL using platform, type, and id parameters (as per API docs)
|
||||||
|
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
||||||
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
|
url.QueryEscape(platform),
|
||||||
|
url.QueryEscape(entityType),
|
||||||
|
url.QueryEscape(entityID))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
|
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
||||||
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
// Get the ID part and remove any query parameters
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.SpotifyID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.TidalURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.AmazonURL, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -495,6 +495,17 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Track item structure for pagination
|
||||||
|
type trackItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -502,15 +513,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []struct {
|
Items []trackItem `json:"items"`
|
||||||
ID string `json:"id"`
|
Next string `json:"next"`
|
||||||
Name string `json:"name"`
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
|
||||||
DiscNumber int `json:"disc_number"`
|
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
|
||||||
Artists []artist `json:"artists"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,10 +531,38 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Collect all tracks (including paginated)
|
||||||
for _, item := range data.Tracks.Items {
|
allTrackItems := data.Tracks.Items
|
||||||
// Fetch ISRC for each track
|
nextURL := data.Tracks.Next
|
||||||
isrc := c.fetchTrackISRC(ctx, item.ID, token)
|
|
||||||
|
// Fetch remaining tracks using pagination (no limit)
|
||||||
|
for nextURL != "" {
|
||||||
|
var pageData struct {
|
||||||
|
Items []trackItem `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
allTrackItems = append(allTrackItems, pageData.Items...)
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||||
|
|
||||||
|
// Collect track IDs for parallel ISRC fetching
|
||||||
|
trackIDs := make([]string, len(allTrackItems))
|
||||||
|
for i, item := range allTrackItems {
|
||||||
|
trackIDs[i] = item.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||||
|
for _, item := range allTrackItems {
|
||||||
|
isrc := isrcMap[item.ID]
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.ID,
|
SpotifyID: item.ID,
|
||||||
@@ -566,6 +598,47 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||||
|
// 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{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||||
|
|
||||||
|
resultMu.Lock()
|
||||||
|
result[id] = isrc
|
||||||
|
resultMu.Unlock()
|
||||||
|
}(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
// First request to get playlist info and first batch of tracks
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
@@ -620,11 +693,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (up to 1000 tracks max)
|
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
maxTracks := 1000
|
|
||||||
|
|
||||||
for nextURL != "" && len(tracks) < maxTracks {
|
for nextURL != "" {
|
||||||
var pageData struct {
|
var pageData struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
@@ -642,9 +714,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(tracks) >= maxTracks {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.Track.ID,
|
SpotifyID: item.Track.ID,
|
||||||
Artists: joinArtists(item.Track.Artists),
|
Artists: joinArtists(item.Track.Artists),
|
||||||
@@ -835,8 +904,16 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set headers (same as PC version baseHeaders)
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
req.Header.Set("sec-ch-ua-platform", "\"Windows\"")
|
||||||
|
req.Header.Set("sec-fetch-dest", "empty")
|
||||||
|
req.Header.Set("sec-fetch-mode", "cors")
|
||||||
|
req.Header.Set("sec-fetch-site", "same-origin")
|
||||||
|
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||||
|
req.Header.Set("Origin", "https://open.spotify.com")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
@@ -863,13 +940,23 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
c.rngMu.Lock()
|
c.rngMu.Lock()
|
||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
chromeMajor := 80 + c.rng.Intn(25)
|
// Use Mac User-Agent format (same as PC version)
|
||||||
chromeBuild := 3000 + c.rng.Intn(1500)
|
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||||
chromePatch := 60 + c.rng.Intn(65)
|
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
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
"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,
|
chromeMajor, chromeBuild, chromePatch,
|
||||||
|
safariMajor, safariMinor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -427,7 +427,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -181,6 +181,106 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "readFileMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendReadFileMetadata(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchDeezerAll":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
|
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getDeezerMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let resourceId = args["resource_id"] as! String
|
||||||
|
let response = GobackendGetDeezerMetadata(resourceType, resourceId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "parseDeezerUrl":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendParseDeezerURLExport(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchDeezerByISRC":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let isrc = args["isrc"] as! String
|
||||||
|
let response = GobackendSearchDeezerByISRC(isrc, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "convertSpotifyToDeezer":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let spotifyId = args["spotify_id"] as! String
|
||||||
|
let response = GobackendConvertSpotifyToDeezer(resourceType, spotifyId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getSpotifyMetadataWithFallback":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "preWarmTrackCache":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let tracksJson = args["tracks"] as! String
|
||||||
|
let _ = GobackendPreWarmTrackCacheJSON(tracksJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getTrackCacheSize":
|
||||||
|
let response = GobackendGetTrackCacheSize()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearTrackCache":
|
||||||
|
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
|
||||||
|
|
||||||
|
// Log methods
|
||||||
|
case "getLogs":
|
||||||
|
let response = GobackendGetLogs()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getLogsSince":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let index = args["index"] as? Int ?? 0
|
||||||
|
let response = GobackendGetLogsSince(Int(index))
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearLogs":
|
||||||
|
GobackendClearLogs()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getLogCount":
|
||||||
|
let response = GobackendGetLogCount()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setLoggingEnabled":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let enabled = args["enabled"] as? Bool ?? false
|
||||||
|
GobackendSetLoggingEnabled(enabled)
|
||||||
|
return nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 744 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 419 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 789 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 752 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.1.5';
|
static const String version = '2.2.9';
|
||||||
static const String buildNumber = '43';
|
static const String buildNumber = '51';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ enum DownloadStatus {
|
|||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error type enum for better error handling
|
||||||
|
enum DownloadErrorType {
|
||||||
|
unknown,
|
||||||
|
notFound, // Track not found on any service
|
||||||
|
rateLimit, // Rate limited by service
|
||||||
|
network, // Network/connection error
|
||||||
|
permission, // File/folder permission error
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -20,8 +29,10 @@ class DownloadItem {
|
|||||||
final String service;
|
final String service;
|
||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
|
final double speedMBps; // Download speed in MB/s
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? qualityOverride; // Override quality for this specific download
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
@@ -31,8 +42,10 @@ class DownloadItem {
|
|||||||
required this.service,
|
required this.service,
|
||||||
this.status = DownloadStatus.queued,
|
this.status = DownloadStatus.queued,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
|
this.speedMBps = 0.0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.errorType,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.qualityOverride,
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
@@ -43,8 +56,10 @@ class DownloadItem {
|
|||||||
String? service,
|
String? service,
|
||||||
DownloadStatus? status,
|
DownloadStatus? status,
|
||||||
double? progress,
|
double? progress,
|
||||||
|
double? speedMBps,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
|
DownloadErrorType? errorType,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
@@ -54,13 +69,33 @@ class DownloadItem {
|
|||||||
service: service ?? this.service,
|
service: service ?? this.service,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
|
errorType: errorType ?? this.errorType,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user-friendly error message based on error type
|
||||||
|
String get errorMessage {
|
||||||
|
if (error == null) return '';
|
||||||
|
|
||||||
|
switch (errorType) {
|
||||||
|
case DownloadErrorType.notFound:
|
||||||
|
return 'Song not found on any service';
|
||||||
|
case DownloadErrorType.rateLimit:
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
||||||
_$DownloadItemFromJson(json);
|
_$DownloadItemFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
||||||
DownloadStatus.queued,
|
DownloadStatus.queued,
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
qualityOverride: json['qualityOverride'] as String?,
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
);
|
);
|
||||||
@@ -27,8 +29,10 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'service': instance.service,
|
'service': instance.service,
|
||||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
|
'speedMBps': instance.speedMBps,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
'qualityOverride': instance.qualityOverride,
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
@@ -41,3 +45,11 @@ const _$DownloadStatusEnumMap = {
|
|||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$DownloadErrorTypeEnumMap = {
|
||||||
|
DownloadErrorType.unknown: 'unknown',
|
||||||
|
DownloadErrorType.notFound: 'notFound',
|
||||||
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
|
DownloadErrorType.network: 'network',
|
||||||
|
DownloadErrorType.permission: 'permission',
|
||||||
|
};
|
||||||
|
|||||||
@@ -18,14 +18,16 @@ class AppSettings {
|
|||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
|
final String historyFilterMode; // all, albums, singles
|
||||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
final bool enableLogging; // Enable detailed logging for debugging
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'qobuz',
|
this.defaultService = 'tidal',
|
||||||
this.audioQuality = 'LOSSLESS',
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.filenameFormat = '{title} - {artist}',
|
this.filenameFormat = '{title} - {artist}',
|
||||||
this.downloadDirectory = '',
|
this.downloadDirectory = '',
|
||||||
@@ -39,11 +41,13 @@ class AppSettings {
|
|||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.historyFilterMode = 'all', // Default: show all
|
||||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
|
this.enableLogging = false, // Default: disabled for performance
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -61,11 +65,13 @@ class AppSettings {
|
|||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
String? metadataSource,
|
String? metadataSource,
|
||||||
|
bool? enableLogging,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -82,11 +88,13 @@ class AppSettings {
|
|||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
metadataSource: metadataSource ?? this.metadataSource,
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
useCustomSpotifyCredentials:
|
useCustomSpotifyCredentials:
|
||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -45,9 +47,11 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
'metadataSource': instance.metadataSource,
|
'metadataSource': instance.metadataSource,
|
||||||
|
'enableLogging': instance.enableLogging,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Track {
|
|||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
@@ -30,6 +31,7 @@ class Track {
|
|||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
|
this.deezerId,
|
||||||
this.availability,
|
this.availability,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,17 +44,23 @@ class ServiceAvailability {
|
|||||||
final bool tidal;
|
final bool tidal;
|
||||||
final bool qobuz;
|
final bool qobuz;
|
||||||
final bool amazon;
|
final bool amazon;
|
||||||
|
final bool deezer;
|
||||||
final String? tidalUrl;
|
final String? tidalUrl;
|
||||||
final String? qobuzUrl;
|
final String? qobuzUrl;
|
||||||
final String? amazonUrl;
|
final String? amazonUrl;
|
||||||
|
final String? deezerUrl;
|
||||||
|
final String? deezerId;
|
||||||
|
|
||||||
const ServiceAvailability({
|
const ServiceAvailability({
|
||||||
this.tidal = false,
|
this.tidal = false,
|
||||||
this.qobuz = false,
|
this.qobuz = false,
|
||||||
this.amazon = false,
|
this.amazon = false,
|
||||||
|
this.deezer = false,
|
||||||
this.tidalUrl,
|
this.tidalUrl,
|
||||||
this.qobuzUrl,
|
this.qobuzUrl,
|
||||||
this.amazonUrl,
|
this.amazonUrl,
|
||||||
|
this.deezerUrl,
|
||||||
|
this.deezerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
availability: json['availability'] == null
|
availability: json['availability'] == null
|
||||||
? null
|
? null
|
||||||
: ServiceAvailability.fromJson(
|
: ServiceAvailability.fromJson(
|
||||||
@@ -37,6 +38,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,9 +47,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
|||||||
tidal: json['tidal'] as bool? ?? false,
|
tidal: json['tidal'] as bool? ?? false,
|
||||||
qobuz: json['qobuz'] as bool? ?? false,
|
qobuz: json['qobuz'] as bool? ?? false,
|
||||||
amazon: json['amazon'] as bool? ?? false,
|
amazon: json['amazon'] as bool? ?? false,
|
||||||
|
deezer: json['deezer'] as bool? ?? false,
|
||||||
tidalUrl: json['tidalUrl'] as String?,
|
tidalUrl: json['tidalUrl'] as String?,
|
||||||
qobuzUrl: json['qobuzUrl'] as String?,
|
qobuzUrl: json['qobuzUrl'] as String?,
|
||||||
amazonUrl: json['amazonUrl'] as String?,
|
amazonUrl: json['amazonUrl'] as String?,
|
||||||
|
deezerUrl: json['deezerUrl'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
||||||
@@ -56,7 +61,10 @@ Map<String, dynamic> _$ServiceAvailabilityToJson(
|
|||||||
'tidal': instance.tidal,
|
'tidal': instance.tidal,
|
||||||
'qobuz': instance.qobuz,
|
'qobuz': instance.qobuz,
|
||||||
'amazon': instance.amazon,
|
'amazon': instance.amazon,
|
||||||
|
'deezer': instance.deezer,
|
||||||
'tidalUrl': instance.tidalUrl,
|
'tidalUrl': instance.tidalUrl,
|
||||||
'qobuzUrl': instance.qobuzUrl,
|
'qobuzUrl': instance.qobuzUrl,
|
||||||
'amazonUrl': instance.amazonUrl,
|
'amazonUrl': instance.amazonUrl,
|
||||||
|
'deezerUrl': instance.deezerUrl,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
@@ -26,6 +27,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
// Apply Spotify credentials to Go backend on load
|
// Apply Spotify credentials to Go backend on load
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
|
// Sync logging state
|
||||||
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +148,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setHistoryFilterMode(String mode) {
|
||||||
|
state = state.copyWith(historyFilterMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setAskQualityBeforeDownload(bool enabled) {
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -187,6 +196,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(metadataSource: source);
|
state = state.copyWith(metadataSource: source);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setEnableLogging(bool enabled) {
|
||||||
|
state = state.copyWith(enableLogging: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
// Sync logging state to LogBuffer
|
||||||
|
LogBuffer.loggingEnabled = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('TrackProvider');
|
||||||
|
|
||||||
class TrackState {
|
class TrackState {
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
@@ -210,28 +213,60 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// Use Deezer or Spotify based on settings
|
// Use Deezer or Spotify based on settings
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
// Debug log to show which source is being used
|
_log.i('Search started: source=$source, query="$query"');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[Search] Using metadata source: $source for query: "$query"');
|
|
||||||
|
|
||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
// ignore: avoid_print
|
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
|
||||||
} else {
|
} else {
|
||||||
|
_log.d('Calling Spotify search API...');
|
||||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
// ignore: avoid_print
|
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) {
|
||||||
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||||
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
|
||||||
|
// Parse tracks with error handling per item
|
||||||
|
final tracks = <Track>[];
|
||||||
|
for (int i = 0; i < trackList.length; i++) {
|
||||||
|
final t = trackList[i];
|
||||||
|
try {
|
||||||
|
if (t is Map<String, dynamic>) {
|
||||||
|
tracks.add(_parseSearchTrack(t));
|
||||||
|
} else {
|
||||||
|
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse artists with error handling per item
|
||||||
|
final artists = <SearchArtist>[];
|
||||||
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
|
final a = artistList[i];
|
||||||
|
try {
|
||||||
|
if (a is Map<String, dynamic>) {
|
||||||
|
artists.add(_parseSearchArtist(a));
|
||||||
|
} else {
|
||||||
|
_log.w('Artist[$i] is not a Map: ${a.runtimeType}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to parse artist[$i]: $e', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -239,9 +274,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return;
|
||||||
// Preserve hasSearchText on error so user stays on search screen
|
_log.e('Search failed: $e', e, stackTrace);
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,18 +345,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Track _parseSearchTrack(Map<String, dynamic> data) {
|
Track _parseSearchTrack(Map<String, dynamic> data) {
|
||||||
|
// Handle duration_ms which might be int or double
|
||||||
|
int durationMs = 0;
|
||||||
|
final durationValue = data['duration_ms'];
|
||||||
|
if (durationValue is int) {
|
||||||
|
durationMs = durationValue;
|
||||||
|
} else if (durationValue is double) {
|
||||||
|
durationMs = durationValue.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: data['name'] as String? ?? '',
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: data['artists'] as String? ?? '',
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: data['album_name'] as String? ?? '',
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist']?.toString(),
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images']?.toString(),
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc']?.toString(),
|
||||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||||
|
final hasValidImage = widget.coverUrl != null &&
|
||||||
|
widget.coverUrl!.isNotEmpty &&
|
||||||
|
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 280,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -169,8 +174,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (widget.coverUrl != null)
|
if (hasValidImage)
|
||||||
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
||||||
|
),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -192,8 +204,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: widget.coverUrl != null
|
child: hasValidImage
|
||||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 280,
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
)
|
||||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
|
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
|
||||||
@@ -266,6 +267,104 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||||
|
// Show loading dialog with progress
|
||||||
|
int currentProgress = 0;
|
||||||
|
int totalTracks = 0;
|
||||||
|
|
||||||
|
// Use StatefulBuilder to update dialog content
|
||||||
|
final dialogContext = context;
|
||||||
|
bool dialogShown = false;
|
||||||
|
StateSetter? setDialogState;
|
||||||
|
|
||||||
|
void showProgressDialog() {
|
||||||
|
if (dialogShown) return;
|
||||||
|
dialogShown = true;
|
||||||
|
showDialog(
|
||||||
|
context: dialogContext,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
setDialogState = setState;
|
||||||
|
return AlertDialog(
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
totalTracks > 0
|
||||||
|
? 'Fetching metadata... $currentProgress/$totalTracks'
|
||||||
|
: 'Reading CSV...',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tracks = await CsvImportService.pickAndParseCsv(
|
||||||
|
onProgress: (current, total) {
|
||||||
|
currentProgress = current;
|
||||||
|
totalTracks = total;
|
||||||
|
if (!dialogShown && total > 0) {
|
||||||
|
showProgressDialog();
|
||||||
|
}
|
||||||
|
setDialogState?.call(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close progress dialog
|
||||||
|
if (dialogShown && mounted) {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.isNotEmpty) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
|
// Optionally show confirmation dialog
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Import Playlist'),
|
||||||
|
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Import'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Added ${tracks.length} tracks to queue'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View Queue',
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to queue tab (handled by main_shell index)
|
||||||
|
// We don't have direct access to set index here easily without provider
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@@ -289,6 +388,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -297,24 +397,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
slivers: [
|
slivers: [
|
||||||
// App Bar - always present
|
// App Bar - always present
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'Home',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'Home',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -329,12 +437,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(height: screenHeight * 0.06),
|
SizedBox(height: screenHeight * 0.06),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(24),
|
width: 96,
|
||||||
|
height: 96,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
color: colorScheme.primary,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
child: Image.asset(
|
||||||
|
'assets/images/logo-transparant.png',
|
||||||
|
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, _, _) => ClipRRect(
|
||||||
|
// Fallback to original logo if transparent one is missing
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -651,6 +774,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
||||||
|
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||||
|
final hasValidImage = artist.imageUrl != null &&
|
||||||
|
artist.imageUrl!.isNotEmpty &&
|
||||||
|
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -666,12 +794,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: artist.imageUrl != null
|
child: hasValidImage
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: artist.imageUrl!,
|
imageUrl: artist.imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 200,
|
memCacheWidth: 200,
|
||||||
memCacheHeight: 200,
|
memCacheHeight: 200,
|
||||||
|
errorWidget: (context, url, error) => Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 44,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
||||||
),
|
),
|
||||||
@@ -736,12 +869,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
onPressed: _clearAndRefresh,
|
onPressed: _clearAndRefresh,
|
||||||
tooltip: 'Clear',
|
tooltip: 'Clear',
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_upload_outlined),
|
||||||
|
onPressed: () => _importCsv(context, ref),
|
||||||
|
tooltip: 'Import CSV',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.paste),
|
icon: const Icon(Icons.paste),
|
||||||
onPressed: _pasteFromClipboard,
|
onPressed: _pasteFromClipboard,
|
||||||
tooltip: 'Paste',
|
tooltip: 'Paste',
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedUrl(String url) {
|
void _handleSharedUrl(String url) {
|
||||||
|
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
// Navigate to Home tab
|
// Navigate to Home tab
|
||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
|
|||||||
@@ -78,6 +78,49 @@ class AboutPage extends StatelessWidget {
|
|||||||
name: AppInfo.originalAuthor,
|
name: AppInfo.originalAuthor,
|
||||||
description: 'Creator of the original SpotiFLAC',
|
description: 'Creator of the original SpotiFLAC',
|
||||||
githubUsername: AppInfo.originalAuthor,
|
githubUsername: AppInfo.originalAuthor,
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'Amonoman',
|
||||||
|
description: 'The talented artist who created our beautiful app logo!',
|
||||||
|
githubUsername: 'Amonoman',
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Special Thanks section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Special Thanks'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'uimaxbai',
|
||||||
|
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
|
||||||
|
githubUsername: 'uimaxbai',
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'sachinsenal0x64',
|
||||||
|
description: 'The original HiFi project creator. The foundation of Tidal integration!',
|
||||||
|
githubUsername: 'sachinsenal0x64',
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_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,
|
||||||
|
),
|
||||||
|
_AboutSettingsItem(
|
||||||
|
icon: Icons.music_note_outlined,
|
||||||
|
title: 'DAB Music',
|
||||||
|
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||||
|
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -206,30 +249,26 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// App logo
|
||||||
// App logo
|
// App logo
|
||||||
Container(
|
Container(
|
||||||
width: 88,
|
width: 88,
|
||||||
height: 88,
|
height: 88,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(24),
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: Image.asset(
|
||||||
borderRadius: BorderRadius.circular(24),
|
'assets/images/logo-transparant.png',
|
||||||
child: Image.asset(
|
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||||
'assets/images/logo.png',
|
fit: BoxFit.contain,
|
||||||
fit: BoxFit.cover,
|
errorBuilder: (_, _, _) => ClipRRect(
|
||||||
errorBuilder: (_, _, _) => Icon(
|
borderRadius: BorderRadius.circular(24),
|
||||||
Icons.music_note,
|
child: Image.asset(
|
||||||
size: 48,
|
'assets/images/logo.png',
|
||||||
color: colorScheme.onPrimaryContainer,
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -374,3 +413,80 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings item with 40x40 icon area to align with contributor avatars
|
||||||
|
class _AboutSettingsItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const _AboutSettingsItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.onTap,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Icon with 40x40 size to match avatar
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 76, // 20 + 40 + 16 = 76 (same as contributor item)
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,68 +27,108 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
leading: IconButton(
|
||||||
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
flexibleSpace: _AppBarTitle(
|
||||||
|
title: 'Appearance',
|
||||||
|
topPadding: topPadding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Preview Section
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: _ThemePreviewCard(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Color section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Color'),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Theme section
|
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_ThemeModeSelector(
|
|
||||||
currentMode: themeSettings.themeMode,
|
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
|
||||||
),
|
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.brightness_2,
|
icon: Icons.wallpaper,
|
||||||
title: 'AMOLED Dark',
|
title: 'Dynamic Color',
|
||||||
subtitle: 'Pure black background for OLED screens',
|
subtitle: 'Use colors from your wallpaper',
|
||||||
value: themeSettings.useAmoled,
|
value: themeSettings.useDynamicColor,
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
onChanged: (value) => ref
|
||||||
|
.read(themeProvider.notifier)
|
||||||
|
.setUseDynamicColor(value),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!themeSettings.useDynamicColor)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: _ColorPalettePicker(
|
||||||
|
currentColor: themeSettings.seedColorValue,
|
||||||
|
onColorSelected: (color) =>
|
||||||
|
ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Color section
|
// Theme section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Theme'),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
_ThemeModeSelector(
|
||||||
icon: Icons.auto_awesome,
|
currentMode: themeSettings.themeMode,
|
||||||
title: 'Dynamic Color',
|
onChanged: (mode) =>
|
||||||
subtitle: 'Use colors from your wallpaper',
|
ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
value: themeSettings.useDynamicColor,
|
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
|
||||||
showDivider: !themeSettings.useDynamicColor,
|
|
||||||
),
|
),
|
||||||
if (!themeSettings.useDynamicColor)
|
if (Theme.of(context).brightness == Brightness.dark)
|
||||||
_ColorPicker(
|
SettingsSwitchItem(
|
||||||
currentColor: themeSettings.seedColorValue,
|
icon: Icons.brightness_2,
|
||||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
title: 'AMOLED Dark',
|
||||||
|
subtitle: 'Pure black background',
|
||||||
|
value: themeSettings.useAmoled,
|
||||||
|
onChanged: (value) =>
|
||||||
|
ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||||
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Layout section
|
// Layout section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Layout'),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_HistoryViewSelector(
|
_HistoryViewSelector(
|
||||||
currentMode: settings.historyViewMode,
|
currentMode: settings.historyViewMode,
|
||||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
onChanged: (mode) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setHistoryViewMode(mode),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Fill remaining for scroll
|
// Fill remaining for scroll
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: SizedBox(height: 32),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -96,11 +136,275 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A simplified preview of how the app looks with current settings
|
||||||
|
class _ThemePreviewCard extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme
|
||||||
|
.surfaceContainerHighest, // Background similar to reference
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Decorative background blobs
|
||||||
|
Positioned(
|
||||||
|
top: -50,
|
||||||
|
right: -50,
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: -30,
|
||||||
|
left: -30,
|
||||||
|
child: Container(
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Foreground "fake UI"
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 260,
|
||||||
|
height: 140,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 12, // Reduced from 20 for performance
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Fake Album Art
|
||||||
|
Container(
|
||||||
|
width: 108,
|
||||||
|
height: 108,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Fake Text Info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.skip_previous,
|
||||||
|
size: 24,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(
|
||||||
|
Icons.play_circle_fill,
|
||||||
|
size: 32,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(
|
||||||
|
Icons.skip_next,
|
||||||
|
size: 24,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Label badge
|
||||||
|
Positioned(
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isDark ? 'Dark Mode' : 'Light Mode',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ColorPalettePicker extends StatelessWidget {
|
||||||
|
final int currentColor;
|
||||||
|
final ValueChanged<Color> onColorSelected;
|
||||||
|
const _ColorPalettePicker({
|
||||||
|
required this.currentColor,
|
||||||
|
required this.onColorSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _colors = [
|
||||||
|
Color(0xFF1DB954),
|
||||||
|
Color(0xFF6750A4),
|
||||||
|
Color(0xFF0061A4),
|
||||||
|
Color(0xFF006E1C),
|
||||||
|
Color(0xFFBA1A1A),
|
||||||
|
Color(0xFF984061),
|
||||||
|
Color(0xFF7D5260),
|
||||||
|
Color(0xFF006874),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: _colors.map((color) {
|
||||||
|
final isSelected = color.toARGB32() == currentColor;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => onColorSelected(color),
|
||||||
|
child: _ColorPaletteItem(color: color, isSelected: isSelected),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ColorPaletteItem extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
|
const _ColorPaletteItem({required this.color, required this.isSelected});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: color,
|
||||||
|
brightness: Theme.of(context).brightness,
|
||||||
|
);
|
||||||
|
final size = 64.0;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Container(color: scheme.primaryContainer)),
|
||||||
|
Expanded(child: Container(color: scheme.tertiaryContainer)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(color: scheme.secondaryContainer),
|
||||||
|
),
|
||||||
|
Expanded(child: Container(color: scheme.surfaceContainer)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.check, size: 16, color: scheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Optimized app bar title with animation
|
/// Optimized app bar title with animation
|
||||||
class _AppBarTitle extends StatelessWidget {
|
class _AppBarTitle extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final double topPadding;
|
final double topPadding;
|
||||||
|
|
||||||
const _AppBarTitle({required this.title, required this.topPadding});
|
const _AppBarTitle({required this.title, required this.topPadding});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -110,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
@@ -132,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
|
|||||||
class _ThemeModeSelector extends StatelessWidget {
|
class _ThemeModeSelector extends StatelessWidget {
|
||||||
final ThemeMode currentMode;
|
final ThemeMode currentMode;
|
||||||
final ValueChanged<ThemeMode> onChanged;
|
final ValueChanged<ThemeMode> onChanged;
|
||||||
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
|
const _ThemeModeSelector({
|
||||||
|
required this.currentMode,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_ThemeModeChip(
|
||||||
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
|
icon: Icons.brightness_auto,
|
||||||
const SizedBox(width: 8),
|
label: 'System',
|
||||||
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
|
isSelected: currentMode == ThemeMode.system,
|
||||||
]),
|
onTap: () => onChanged(ThemeMode.system),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ThemeModeChip(
|
||||||
|
icon: Icons.light_mode,
|
||||||
|
label: 'Light',
|
||||||
|
isSelected: currentMode == ThemeMode.light,
|
||||||
|
onTap: () => onChanged(ThemeMode.light),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ThemeModeChip(
|
||||||
|
icon: Icons.dark_mode,
|
||||||
|
label: 'Dark',
|
||||||
|
isSelected: currentMode == ThemeMode.dark,
|
||||||
|
onTap: () => onChanged(ThemeMode.dark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,27 +480,41 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ThemeModeChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
// Unselected chips need contrast with card background
|
||||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
||||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: !isDark && !isSelected
|
border: !isDark && !isSelected
|
||||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
? Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -185,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -200,49 +556,13 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ColorPicker extends StatelessWidget {
|
|
||||||
final int currentColor;
|
|
||||||
final ValueChanged<Color> onColorSelected;
|
|
||||||
const _ColorPicker({required this.currentColor, required this.onColorSelected});
|
|
||||||
|
|
||||||
static const _colors = [
|
|
||||||
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
|
|
||||||
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
|
||||||
final isSelected = color.toARGB32() == currentColor;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onColorSelected(color),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
width: 44, height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color, shape: BoxShape.circle,
|
|
||||||
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
|
|
||||||
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
|
|
||||||
),
|
|
||||||
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList()),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HistoryViewSelector extends StatelessWidget {
|
class _HistoryViewSelector extends StatelessWidget {
|
||||||
final String currentMode;
|
final String currentMode;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
|
const _HistoryViewSelector({
|
||||||
|
required this.currentMode,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -254,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||||
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Text(
|
||||||
|
'History View',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ViewModeChip(
|
||||||
|
icon: Icons.view_list,
|
||||||
|
label: 'List',
|
||||||
|
isSelected: currentMode == 'list',
|
||||||
|
onTap: () => onChanged('list'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ViewModeChip(
|
||||||
|
icon: Icons.grid_view,
|
||||||
|
label: 'Grid',
|
||||||
|
isSelected: currentMode == 'grid',
|
||||||
|
onTap: () => onChanged('grid'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Row(children: [
|
|
||||||
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
|
|
||||||
]),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -272,25 +609,39 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ViewModeChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
// Unselected chips need contrast with card background
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: !isDark && !isSelected
|
border: !isDark && !isSelected
|
||||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
? Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -301,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,16 +28,25 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) /
|
||||||
|
(maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(
|
||||||
|
left: leftPadding,
|
||||||
|
bottom: 16,
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Download',
|
'Download',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -51,89 +60,117 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Service section
|
// Service section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
const SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: 'Service'),
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
_ServiceSelector(
|
child: SettingsGroup(
|
||||||
currentService: settings.defaultService,
|
children: [
|
||||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
_ServiceSelector(
|
||||||
),
|
currentService: settings.defaultService,
|
||||||
],
|
onChanged: (service) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDefaultService(service),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Quality section
|
// Quality section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')),
|
const SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: 'Audio Quality'),
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
SettingsSwitchItem(
|
child: SettingsGroup(
|
||||||
icon: Icons.tune,
|
children: [
|
||||||
title: 'Ask Before Download',
|
SettingsSwitchItem(
|
||||||
subtitle: 'Choose quality for each download',
|
icon: Icons.tune,
|
||||||
value: settings.askQualityBeforeDownload,
|
title: 'Ask Before Download',
|
||||||
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value),
|
subtitle: 'Choose quality for each download',
|
||||||
),
|
value: settings.askQualityBeforeDownload,
|
||||||
if (!settings.askQualityBeforeDownload) ...[
|
onChanged: (value) => ref
|
||||||
_QualityOption(
|
.read(settingsProvider.notifier)
|
||||||
title: 'FLAC Lossless',
|
.setAskQualityBeforeDownload(value),
|
||||||
subtitle: '16-bit / 44.1kHz',
|
|
||||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
|
|
||||||
),
|
),
|
||||||
_QualityOption(
|
if (!settings.askQualityBeforeDownload) ...[
|
||||||
title: 'Hi-Res FLAC',
|
_QualityOption(
|
||||||
subtitle: '24-bit / up to 96kHz',
|
title: 'FLAC Lossless',
|
||||||
isSelected: settings.audioQuality == 'HI_RES',
|
subtitle: '16-bit / 44.1kHz',
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'),
|
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('LOSSLESS'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC',
|
||||||
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('HI_RES'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC Max',
|
||||||
|
subtitle: '24-bit / up to 192kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// File settings section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'File Settings'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.text_fields,
|
||||||
|
title: 'Filename Format',
|
||||||
|
subtitle: settings.filenameFormat,
|
||||||
|
onTap: () => _showFormatEditor(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.filenameFormat,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_QualityOption(
|
SettingsItem(
|
||||||
title: 'Hi-Res FLAC Max',
|
icon: Icons.folder_outlined,
|
||||||
subtitle: '24-bit / up to 192kHz',
|
title: 'Download Directory',
|
||||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
subtitle: settings.downloadDirectory.isEmpty
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'),
|
? (Platform.isIOS
|
||||||
|
? 'App Documents Folder'
|
||||||
|
: 'Music/SpotiFLAC')
|
||||||
|
: settings.downloadDirectory,
|
||||||
|
onTap: () => _pickDirectory(context, ref),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.create_new_folder_outlined,
|
||||||
|
title: 'Folder Organization',
|
||||||
|
subtitle: _getFolderOrganizationLabel(
|
||||||
|
settings.folderOrganization,
|
||||||
|
),
|
||||||
|
onTap: () => _showFolderOrganizationPicker(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.folderOrganization,
|
||||||
|
),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// File settings section
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'File Settings')),
|
],
|
||||||
SliverToBoxAdapter(
|
),
|
||||||
child: SettingsGroup(
|
|
||||||
children: [
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.text_fields,
|
|
||||||
title: 'Filename Format',
|
|
||||||
subtitle: settings.filenameFormat,
|
|
||||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.folder_outlined,
|
|
||||||
title: 'Download Directory',
|
|
||||||
subtitle: settings.downloadDirectory.isEmpty
|
|
||||||
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
|
|
||||||
: settings.downloadDirectory,
|
|
||||||
onTap: () => _pickDirectory(context, ref),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.create_new_folder_outlined,
|
|
||||||
title: 'Folder Organization',
|
|
||||||
subtitle: _getFolderOrganizationLabel(settings.folderOrganization),
|
|
||||||
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
|
|
||||||
showDivider: false,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,26 +178,176 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||||
final controller = TextEditingController(text: current);
|
final controller = TextEditingController(text: current);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final tags = [
|
||||||
|
'{artist}',
|
||||||
|
'{title}',
|
||||||
|
'{album}',
|
||||||
|
'{track}',
|
||||||
|
'{year}',
|
||||||
|
'{disc}',
|
||||||
|
];
|
||||||
|
|
||||||
|
void insertTag(String tag) {
|
||||||
|
final text = controller.text;
|
||||||
|
final selection = controller.selection;
|
||||||
|
final start = selection.start >= 0 ? selection.start : text.length;
|
||||||
|
final end = selection.end >= 0 ? selection.end : text.length;
|
||||||
|
|
||||||
|
String insertion = tag;
|
||||||
|
if (start > 0) {
|
||||||
|
final before = text.substring(0, start);
|
||||||
|
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
|
||||||
|
if (!before.trim().endsWith('-')) {
|
||||||
|
insertion = ' - $tag';
|
||||||
|
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||||
|
// If ends with '-' but no space, add space
|
||||||
|
insertion = ' $tag';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newText = text.replaceRange(start, end, insertion);
|
||||||
|
controller.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(offset: start + insertion.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context, isScrollControlled: true,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
backgroundColor: colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (context) => Padding(
|
builder: (context) => Padding(
|
||||||
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
|
padding: EdgeInsets.only(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
),
|
||||||
const SizedBox(height: 16),
|
child: SingleChildScrollView(
|
||||||
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
|
child: SafeArea(
|
||||||
const SizedBox(height: 16),
|
child: Padding(
|
||||||
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
|
padding: const EdgeInsets.all(24),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Column(
|
||||||
const SizedBox(height: 24),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
children: [
|
||||||
const SizedBox(width: 8),
|
Center(
|
||||||
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
|
child: Container(
|
||||||
]),
|
width: 32,
|
||||||
]),
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Filename Format',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Customize how your files are named.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '{artist} - {title}',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Tap to insert tag:',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: tags.map((tag) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(tag),
|
||||||
|
onPressed: () => insertTag(tag),
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
side: BorderSide.none,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFilenameFormat(controller.text);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Save Format'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,7 +359,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
// Android: Use file picker
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
if (result != null) {
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +370,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (ctx) => SafeArea(
|
builder: (ctx) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -189,13 +380,20 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
child: Text(
|
||||||
|
'Download Location',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -205,7 +403,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadDirectory(dir.path);
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -218,7 +418,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadDirectory(result);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -232,12 +434,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -264,12 +472,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showFolderOrganizationPicker(BuildContext context, WidgetRef ref, String current) {
|
void _showFolderOrganizationPicker(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String current,
|
||||||
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -277,39 +491,61 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text('Folder Organization', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
child: Text(
|
||||||
|
'Folder Organization',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text('Organize downloaded files into folders', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Text(
|
||||||
|
'Organize downloaded files into folders',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'None',
|
title: 'None',
|
||||||
subtitle: 'All files in download folder',
|
subtitle: 'All files in download folder',
|
||||||
example: 'SpotiFLAC/Track.flac',
|
example: 'SpotiFLAC/Track.flac',
|
||||||
isSelected: current == 'none',
|
isSelected: current == 'none',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist',
|
title: 'By Artist',
|
||||||
subtitle: 'Separate folder for each artist',
|
subtitle: 'Separate folder for each artist',
|
||||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
isSelected: current == 'artist',
|
isSelected: current == 'artist',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Album',
|
title: 'By Album',
|
||||||
subtitle: 'Separate folder for each album',
|
subtitle: 'Separate folder for each album',
|
||||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
isSelected: current == 'album',
|
isSelected: current == 'album',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist & Album',
|
title: 'By Artist & Album',
|
||||||
subtitle: 'Nested folders for artist and album',
|
subtitle: 'Nested folders for artist and album',
|
||||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
isSelected: current == 'artist_album',
|
isSelected: current == 'artist_album',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
@@ -322,19 +558,39 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
class _ServiceSelector extends StatelessWidget {
|
class _ServiceSelector extends StatelessWidget {
|
||||||
final String currentService;
|
final String currentService;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _ServiceSelector({required this.currentService, required this.onChanged});
|
const _ServiceSelector({
|
||||||
|
required this.currentService,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_ServiceChip(
|
||||||
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
|
icon: Icons.music_note,
|
||||||
const SizedBox(width: 8),
|
label: 'Tidal',
|
||||||
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
|
isSelected: currentService == 'tidal',
|
||||||
]),
|
onTap: () => onChanged('tidal'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.album,
|
||||||
|
label: 'Qobuz',
|
||||||
|
isSelected: currentService == 'qobuz',
|
||||||
|
onTap: () => onChanged('qobuz'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.shopping_bag,
|
||||||
|
label: 'Amazon',
|
||||||
|
isSelected: currentService == 'amazon',
|
||||||
|
onTap: () => onChanged('amazon'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,17 +600,25 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ServiceChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Material(
|
||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
@@ -364,13 +628,29 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -384,7 +664,13 @@ class _QualityOption extends StatelessWidget {
|
|||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool showDivider;
|
final bool showDivider;
|
||||||
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true});
|
const _QualityOption({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -404,11 +690,16 @@ class _QualityOption extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
isSelected
|
isSelected
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
],
|
],
|
||||||
@@ -434,7 +725,13 @@ class _FolderOption extends StatelessWidget {
|
|||||||
final String example;
|
final String example;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _FolderOption({required this.title, required this.subtitle, required this.example, required this.isSelected, required this.onTap});
|
const _FolderOption({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.example,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -447,10 +744,19 @@ class _FolderOption extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(subtitle),
|
Text(subtitle),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(example, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: colorScheme.primary)),
|
Text(
|
||||||
|
example,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
|
trailing: isSelected
|
||||||
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,801 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class LogScreen extends StatefulWidget {
|
||||||
|
const LogScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LogScreen> createState() => _LogScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogScreenState extends State<LogScreen> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _selectedLevel = 'ALL';
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _autoScroll = true;
|
||||||
|
|
||||||
|
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
LogBuffer().addListener(_onLogUpdate);
|
||||||
|
// Start polling Go backend logs
|
||||||
|
LogBuffer().startGoLogPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
LogBuffer().removeListener(_onLogUpdate);
|
||||||
|
// Stop polling when leaving screen
|
||||||
|
LogBuffer().stopGoLogPolling();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLogUpdate() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
if (_autoScroll && _scrollController.hasClients) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogEntry> get _filteredLogs {
|
||||||
|
return LogBuffer().filter(
|
||||||
|
level: _selectedLevel,
|
||||||
|
search: _searchQuery.isEmpty ? null : _searchQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyLogs() {
|
||||||
|
final logs = LogBuffer().export();
|
||||||
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Logs copied to clipboard'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareLogs() {
|
||||||
|
final logs = LogBuffer().export();
|
||||||
|
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearLogs() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear Logs'),
|
||||||
|
content: const Text('Are you sure you want to clear all logs?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
LogBuffer().clear();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getLevelColor(String level, ColorScheme colorScheme) {
|
||||||
|
switch (level) {
|
||||||
|
case 'ERROR':
|
||||||
|
case 'FATAL':
|
||||||
|
return colorScheme.error;
|
||||||
|
case 'WARN':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'INFO':
|
||||||
|
return colorScheme.primary;
|
||||||
|
case 'DEBUG':
|
||||||
|
default:
|
||||||
|
return colorScheme.onSurfaceVariant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
final logs = _filteredLogs;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: true,
|
||||||
|
child: Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
// Collapsing App Bar with back button - same as other settings pages
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 120 + topPadding,
|
||||||
|
collapsedHeight: kToolbarHeight,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||||
|
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||||
|
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
tooltip: 'Copy logs',
|
||||||
|
onPressed: _copyLogs,
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'share':
|
||||||
|
_shareLogs();
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
_clearLogs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'share',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.share),
|
||||||
|
title: Text('Share logs'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'clear',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete_outline),
|
||||||
|
title: Text('Clear logs'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
'Logs',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Filter section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Filter'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
// Level filter
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Filter logs by severity',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: _selectedLevel,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: _levels.map((level) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: level,
|
||||||
|
child: Text(
|
||||||
|
level,
|
||||||
|
style: TextStyle(
|
||||||
|
color: level == 'ALL'
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: _getLevelColor(level, colorScheme),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() => _selectedLevel = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 56,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
// Search field
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search logs...',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _searchQuery = '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _searchQuery = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Log entries section
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(
|
||||||
|
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error summary card - shows detected issues
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _LogSummaryCard(logs: LogBuffer().entries),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Log list
|
||||||
|
logs.isEmpty
|
||||||
|
? SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.article_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No logs yet',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Logs will appear here as you use the app',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
...logs.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final log = entry.value;
|
||||||
|
return _LogEntryTile(
|
||||||
|
entry: log,
|
||||||
|
levelColor: _getLevelColor(log.level, colorScheme),
|
||||||
|
showDivider: index < logs.length - 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogEntryTile extends StatelessWidget {
|
||||||
|
final LogEntry entry;
|
||||||
|
final Color levelColor;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const _LogEntryTile({
|
||||||
|
required this.entry,
|
||||||
|
required this.levelColor,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isError = entry.level == 'ERROR' || entry.level == 'FATAL';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isError
|
||||||
|
? colorScheme.errorContainer.withValues(alpha: 0.2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: time, level, tag
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.formattedTime,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: levelColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.level,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: levelColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (entry.isFromGo) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.teal.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Go',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.teal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
entry.tag,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Message
|
||||||
|
Text(
|
||||||
|
entry.message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Error if present
|
||||||
|
if (entry.error != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
entry.error!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.error,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary card showing detected issues in logs
|
||||||
|
class _LogSummaryCard extends StatelessWidget {
|
||||||
|
final List<LogEntry> logs;
|
||||||
|
|
||||||
|
const _LogSummaryCard({required this.logs});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Analyze logs for issues
|
||||||
|
final analysis = _analyzeLogs();
|
||||||
|
|
||||||
|
// Don't show if no issues detected
|
||||||
|
if (!analysis.hasIssues) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: analysis.hasISPBlocking
|
||||||
|
? colorScheme.errorContainer.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Issue Summary',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ISP Blocking detected
|
||||||
|
if (analysis.hasISPBlocking) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.block,
|
||||||
|
label: 'ISP BLOCKING DETECTED',
|
||||||
|
description: 'Your ISP may be blocking access to download services',
|
||||||
|
suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
|
||||||
|
color: colorScheme.error,
|
||||||
|
domains: analysis.blockedDomains,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if (analysis.hasRateLimit) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.speed,
|
||||||
|
label: 'RATE LIMITED',
|
||||||
|
description: 'Too many requests to the service',
|
||||||
|
suggestion: 'Wait a few minutes before trying again',
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Network errors
|
||||||
|
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.wifi_off,
|
||||||
|
label: 'NETWORK ERROR',
|
||||||
|
description: 'Connection issues detected',
|
||||||
|
suggestion: 'Check your internet connection',
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Track not found
|
||||||
|
if (analysis.hasNotFound) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.search_off,
|
||||||
|
label: 'TRACK NOT FOUND',
|
||||||
|
description: 'Some tracks could not be found on download services',
|
||||||
|
suggestion: 'The track may not be available in lossless quality',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Error count
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Total errors: ${analysis.errorCount}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_LogAnalysis _analyzeLogs() {
|
||||||
|
int errorCount = 0;
|
||||||
|
bool hasISPBlocking = false;
|
||||||
|
bool hasRateLimit = false;
|
||||||
|
bool hasNetworkError = false;
|
||||||
|
bool hasNotFound = false;
|
||||||
|
final Set<String> blockedDomains = {};
|
||||||
|
|
||||||
|
for (final log in logs) {
|
||||||
|
if (log.level == 'ERROR' || log.level == 'FATAL') {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final msgLower = log.message.toLowerCase();
|
||||||
|
final errorLower = (log.error ?? '').toLowerCase();
|
||||||
|
final combined = '$msgLower $errorLower';
|
||||||
|
|
||||||
|
// Check for ISP blocking (detected by Go backend)
|
||||||
|
if (combined.contains('isp blocking') ||
|
||||||
|
combined.contains('isp may be') ||
|
||||||
|
combined.contains('blocked by isp') ||
|
||||||
|
combined.contains('connection reset') ||
|
||||||
|
combined.contains('connection refused')) {
|
||||||
|
hasISPBlocking = true;
|
||||||
|
|
||||||
|
// Try to extract domain
|
||||||
|
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
||||||
|
if (domainMatch != null) {
|
||||||
|
blockedDomains.add(domainMatch.group(1)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rate limiting
|
||||||
|
if (combined.contains('rate limit') ||
|
||||||
|
combined.contains('429') ||
|
||||||
|
combined.contains('too many requests')) {
|
||||||
|
hasRateLimit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for network errors
|
||||||
|
if (combined.contains('connection') ||
|
||||||
|
combined.contains('timeout') ||
|
||||||
|
combined.contains('network') ||
|
||||||
|
combined.contains('dial')) {
|
||||||
|
hasNetworkError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for not found
|
||||||
|
if (combined.contains('not found') ||
|
||||||
|
combined.contains('no results') ||
|
||||||
|
combined.contains('could not find')) {
|
||||||
|
hasNotFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _LogAnalysis(
|
||||||
|
errorCount: errorCount,
|
||||||
|
hasISPBlocking: hasISPBlocking,
|
||||||
|
hasRateLimit: hasRateLimit,
|
||||||
|
hasNetworkError: hasNetworkError,
|
||||||
|
hasNotFound: hasNotFound,
|
||||||
|
blockedDomains: blockedDomains.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogAnalysis {
|
||||||
|
final int errorCount;
|
||||||
|
final bool hasISPBlocking;
|
||||||
|
final bool hasRateLimit;
|
||||||
|
final bool hasNetworkError;
|
||||||
|
final bool hasNotFound;
|
||||||
|
final List<String> blockedDomains;
|
||||||
|
|
||||||
|
_LogAnalysis({
|
||||||
|
required this.errorCount,
|
||||||
|
required this.hasISPBlocking,
|
||||||
|
required this.hasRateLimit,
|
||||||
|
required this.hasNetworkError,
|
||||||
|
required this.hasNotFound,
|
||||||
|
required this.blockedDomains,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IssueBadge extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final String suggestion;
|
||||||
|
final Color color;
|
||||||
|
final List<String>? domains;
|
||||||
|
|
||||||
|
const _IssueBadge({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
required this.suggestion,
|
||||||
|
required this.color,
|
||||||
|
this.domains,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: color),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (domains != null && domains!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Affected: ${domains!.join(", ")}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
suggestion,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
|
|||||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class SettingsTab extends ConsumerWidget {
|
class SettingsTab extends ConsumerWidget {
|
||||||
@@ -13,29 +14,38 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar
|
// Collapsing App Bar
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'Settings',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'Settings',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -67,10 +77,16 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Second group: About
|
// Second group: Logs & About
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.article_outlined,
|
||||||
|
title: 'Logs',
|
||||||
|
subtitle: 'View app logs for debugging',
|
||||||
|
onTap: () => _navigateTo(context, const LogScreen()),
|
||||||
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
title: 'About',
|
title: 'About',
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
String? _selectedDirectory;
|
String? _selectedDirectory;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
|
|
||||||
|
// Spotify API credentials
|
||||||
|
final _clientIdController = TextEditingController();
|
||||||
|
final _clientSecretController = TextEditingController();
|
||||||
|
bool _useSpotifyApi = false;
|
||||||
|
bool _showClientSecret = false;
|
||||||
|
|
||||||
// Total steps: Storage -> Notification (Android 13+) -> Folder
|
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
|
||||||
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
|
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,6 +39,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_initDeviceInfo();
|
_initDeviceInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_clientIdController.dispose();
|
||||||
|
_clientSecretController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initDeviceInfo() async {
|
Future<void> _initDeviceInfo() async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
@@ -53,24 +66,38 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
// Check storage permission
|
// Check storage permission
|
||||||
PermissionStatus storageStatus;
|
bool storageGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
storageStatus = await Permission.audio.status;
|
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
||||||
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
final audioStatus = await Permission.audio.status;
|
||||||
|
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
||||||
|
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
storageStatus = await Permission.manageExternalStorage.status;
|
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
||||||
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
||||||
|
storageGranted = manageStatus.isGranted;
|
||||||
} else {
|
} else {
|
||||||
storageStatus = await Permission.storage.status;
|
// Android 10 and below: Use legacy storage permission
|
||||||
|
final storageStatus = await Permission.storage.status;
|
||||||
|
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
||||||
|
storageGranted = storageStatus.isGranted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint('[Permission] Final storageGranted=$storageGranted');
|
||||||
|
|
||||||
// Check notification permission (Android 13+)
|
// Check notification permission (Android 13+)
|
||||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
notificationStatus = await Permission.notification.status;
|
notificationStatus = await Permission.notification.status;
|
||||||
|
debugPrint('[Permission] Notification=$notificationStatus');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_storagePermissionGranted = storageStatus.isGranted;
|
_storagePermissionGranted = storageGranted;
|
||||||
_notificationPermissionGranted = notificationStatus.isGranted;
|
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,17 +111,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
setState(() => _storagePermissionGranted = true);
|
setState(() => _storagePermissionGranted = true);
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
PermissionStatus status;
|
bool allGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// Android 13+: Use audio permission
|
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
||||||
status = await Permission.audio.request();
|
|
||||||
|
// First check/request MANAGE_EXTERNAL_STORAGE
|
||||||
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
if (!manageStatus.isGranted) {
|
||||||
|
if (mounted) {
|
||||||
|
final shouldOpen = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Storage Access Required'),
|
||||||
|
content: const Text(
|
||||||
|
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
|
||||||
|
'Please enable "Allow access to manage all files" in the next screen.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Open Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldOpen == true) {
|
||||||
|
await Permission.manageExternalStorage.request();
|
||||||
|
// Re-check after returning from settings
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then request READ_MEDIA_AUDIO (this shows a dialog)
|
||||||
|
var audioStatus = await Permission.audio.status;
|
||||||
|
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
||||||
|
audioStatus = await Permission.audio.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
|
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE
|
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
||||||
// This opens system settings, not a dialog
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
status = await Permission.manageExternalStorage.status;
|
if (!manageStatus.isGranted) {
|
||||||
if (!status.isGranted) {
|
|
||||||
// Show explanation dialog first
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final shouldOpen = await showDialog<bool>(
|
final shouldOpen = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -118,23 +185,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldOpen == true) {
|
if (shouldOpen == true) {
|
||||||
status = await Permission.manageExternalStorage.request();
|
await Permission.manageExternalStorage.request();
|
||||||
|
// Re-check after returning from settings
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
allGranted = manageStatus.isGranted;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Android 10 and below: Use legacy storage permission
|
// Android 10 and below: Use legacy storage permission
|
||||||
status = await Permission.storage.request();
|
final status = await Permission.storage.request();
|
||||||
|
allGranted = status.isGranted;
|
||||||
|
|
||||||
|
if (status.isPermanentlyDenied) {
|
||||||
|
_showPermissionDeniedDialog('Storage');
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.isGranted) {
|
if (allGranted) {
|
||||||
setState(() => _storagePermissionGranted = true);
|
setState(() => _storagePermissionGranted = true);
|
||||||
} else if (status.isPermanentlyDenied) {
|
|
||||||
_showPermissionDeniedDialog('Storage');
|
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
|
const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +435,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
||||||
|
|
||||||
|
// Save Spotify credentials if provided
|
||||||
|
if (_useSpotifyApi &&
|
||||||
|
_clientIdController.text.trim().isNotEmpty &&
|
||||||
|
_clientSecretController.text.trim().isNotEmpty) {
|
||||||
|
ref.read(settingsProvider.notifier).setSpotifyCredentials(
|
||||||
|
_clientIdController.text.trim(),
|
||||||
|
_clientSecretController.text.trim(),
|
||||||
|
);
|
||||||
|
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
||||||
|
// Set search source to Spotify when using custom credentials
|
||||||
|
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||||
|
} else {
|
||||||
|
// Use Deezer as default search source
|
||||||
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
|
}
|
||||||
|
|
||||||
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -436,8 +530,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||||
final steps = _androidSdkVersion >= 33
|
final steps = _androidSdkVersion >= 33
|
||||||
? ['Storage', 'Notification', 'Folder']
|
? ['Storage', 'Notification', 'Folder', 'Spotify']
|
||||||
: ['Permission', 'Folder'];
|
: ['Permission', 'Folder', 'Spotify'];
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -461,48 +555,61 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
||||||
final isActive = _currentStep >= step;
|
final isActive = _currentStep >= step;
|
||||||
final isCompleted = _isStepCompleted(step);
|
final isCompleted = _isStepCompleted(step);
|
||||||
|
final isCurrent = _currentStep == step;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
AnimatedContainer(
|
||||||
width: 32,
|
duration: const Duration(milliseconds: 200),
|
||||||
height: 32,
|
width: 36,
|
||||||
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: isCompleted
|
color: isCompleted
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
: isCurrent
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
border: isCurrent && !isCompleted
|
||||||
|
? Border.all(color: colorScheme.primary, width: 2)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: isCompleted
|
child: isCompleted
|
||||||
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
|
? Icon(Icons.check_rounded, size: 20, color: colorScheme.onPrimary)
|
||||||
: Text('${step + 1}',
|
: Text('${step + 1}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
color: isCurrent ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold)),
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Text(label,
|
Text(label,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
|
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isStepCompleted(int step) {
|
bool _isStepCompleted(int step) {
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// 3 steps: Storage, Notification, Folder
|
// 4 steps: Storage, Notification, Folder, Spotify
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _notificationPermissionGranted;
|
case 1: return _notificationPermissionGranted;
|
||||||
case 2: return _selectedDirectory != null;
|
case 2: return _selectedDirectory != null;
|
||||||
|
case 3: return false; // Spotify step never shows checkmark (optional)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2 steps: Permission, Folder
|
// 3 steps: Permission, Folder, Spotify
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _selectedDirectory != null;
|
case 1: return _selectedDirectory != null;
|
||||||
|
case 2: return false; // Spotify step never shows checkmark (optional)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -514,11 +621,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
case 1: return _buildNotificationPermissionStep(colorScheme);
|
case 1: return _buildNotificationPermissionStep(colorScheme);
|
||||||
case 2: return _buildDirectoryStep(colorScheme);
|
case 2: return _buildDirectoryStep(colorScheme);
|
||||||
|
case 3: return _buildSpotifyApiStep(colorScheme);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
case 1: return _buildDirectoryStep(colorScheme);
|
case 1: return _buildDirectoryStep(colorScheme);
|
||||||
|
case 2: return _buildSpotifyApiStep(colorScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
@@ -529,35 +638,50 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _storagePermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_storagePermissionGranted ? Icons.check_rounded : Icons.folder_open_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _storagePermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Padding(
|
||||||
_storagePermissionGranted
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
? 'You can now proceed to the next step.'
|
child: Text(
|
||||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
_storagePermissionGranted
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
? 'You can now proceed to the next step.'
|
||||||
textAlign: TextAlign.center,
|
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
if (!_storagePermissionGranted)
|
if (!_storagePermissionGranted)
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _requestStoragePermission,
|
onPressed: _isLoading ? null : _requestStoragePermission,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.security),
|
: const Icon(Icons.security_rounded),
|
||||||
label: const Text('Grant Permission'),
|
label: const Text('Grant Permission'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -568,39 +692,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _notificationPermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_notificationPermissionGranted ? Icons.check_rounded : Icons.notifications_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: _notificationPermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Padding(
|
||||||
_notificationPermissionGranted
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
? 'You will receive download progress notifications.'
|
child: Text(
|
||||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
_notificationPermissionGranted
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
? 'You will receive download progress notifications.'
|
||||||
textAlign: TextAlign.center,
|
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
if (!_notificationPermissionGranted) ...[
|
if (!_notificationPermissionGranted) ...[
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _requestNotificationPermission,
|
onPressed: _isLoading ? null : _requestNotificationPermission,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.notifications_active),
|
: const Icon(Icons.notifications_active_rounded),
|
||||||
label: const Text('Enable Notifications'),
|
label: const Text('Enable Notifications'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _skipNotificationPermission,
|
onPressed: _skipNotificationPermission,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
child: const Text('Skip for now'),
|
child: const Text('Skip for now'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -613,51 +755,226 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_selectedDirectory != null ? Icons.folder : Icons.create_new_folder,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _selectedDirectory != null ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _selectedDirectory != null ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_selectedDirectory != null ? Icons.folder_rounded : Icons.create_new_folder_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _selectedDirectory != null ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (_selectedDirectory != null)
|
if (_selectedDirectory != null)
|
||||||
Container(
|
Card(
|
||||||
padding: const EdgeInsets.all(12),
|
elevation: 0,
|
||||||
decoration: BoxDecoration(
|
color: colorScheme.surfaceContainerHigh,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.folder, color: colorScheme.primary, size: 20),
|
Icon(Icons.folder_rounded, color: colorScheme.primary, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 12),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(_selectedDirectory!,
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
_selectedDirectory!,
|
||||||
overflow: TextOverflow.ellipsis),
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Text('Select a folder where your downloaded music will be saved.',
|
Padding(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
textAlign: TextAlign.center),
|
child: Text(
|
||||||
const SizedBox(height: 20),
|
'Select a folder where your downloaded music will be saved.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _selectDirectory,
|
onPressed: _isLoading ? null : _selectDirectory,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
|
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
|
||||||
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSpotifyApiStep(ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Icon with container background (M3 style)
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _useSpotifyApi ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.api_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _useSpotifyApi ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Spotify API (Optional)',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Add your Spotify API credentials for better search results, or skip to use Deezer instead.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Toggle card (M3 style)
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SwitchListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
title: Text('Use Spotify API', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
subtitle: Text(
|
||||||
|
_useSpotifyApi ? 'Enter your credentials below' : 'Using Deezer (no account needed)',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
secondary: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _useSpotifyApi ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_useSpotifyApi ? Icons.music_note_rounded : Icons.album_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: _useSpotifyApi ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: _useSpotifyApi,
|
||||||
|
onChanged: (value) => setState(() => _useSpotifyApi = value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Credentials form (animated)
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
child: _useSpotifyApi ? Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Client ID
|
||||||
|
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _clientIdController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter Spotify Client ID',
|
||||||
|
prefixIcon: const Icon(Icons.key_rounded),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Client Secret
|
||||||
|
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _clientSecretController,
|
||||||
|
obscureText: !_showClientSecret,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter Spotify Client Secret',
|
||||||
|
prefixIcon: const Icon(Icons.lock_rounded),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||||
|
onPressed: () => setState(() => _showClientSecret = !_showClientSecret),
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Info banner
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onTertiaryContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Get credentials from developer.spotify.com',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) : const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -666,6 +983,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
||||||
final isLastStep = _currentStep == _totalSteps - 1;
|
final isLastStep = _currentStep == _totalSteps - 1;
|
||||||
final canProceed = _isStepCompleted(_currentStep);
|
final canProceed = _isStepCompleted(_currentStep);
|
||||||
|
|
||||||
|
// For Spotify step, check if credentials are valid when enabled
|
||||||
|
final isSpotifyStepValid = !_useSpotifyApi ||
|
||||||
|
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -674,8 +995,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (_currentStep > 0)
|
if (_currentStep > 0)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
label: const Text('Back'),
|
label: const Text('Back'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const SizedBox(width: 100),
|
const SizedBox(width: 100),
|
||||||
@@ -684,20 +1008,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (!isLastStep)
|
if (!isLastStep)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
|
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward_rounded, size: 18)],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
|
onPressed: isSpotifyStepValid && !_isLoading ? _completeSetup : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
|
children: [
|
||||||
|
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.check_rounded, size: 18),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final file = File(widget.item.filePath);
|
final file = File(widget.item.filePath);
|
||||||
final exists = await file.exists();
|
final exists = await file.exists();
|
||||||
int? size;
|
int? size;
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
try {
|
try {
|
||||||
size = await file.length();
|
size = await file.length();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_fileExists = exists;
|
_fileExists = exists;
|
||||||
@@ -55,7 +57,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use data directly from history item (cached from download)
|
||||||
DownloadHistoryItem get item => widget.item;
|
DownloadHistoryItem get item => widget.item;
|
||||||
|
String get trackName => item.trackName;
|
||||||
|
String get artistName => item.artistName;
|
||||||
|
String get albumName => item.albumName;
|
||||||
|
String? get albumArtist => item.albumArtist;
|
||||||
|
int? get trackNumber => item.trackNumber;
|
||||||
|
int? get discNumber => item.discNumber;
|
||||||
|
String? get releaseDate => item.releaseDate;
|
||||||
|
String? get isrc => item.isrc;
|
||||||
|
int? get bitDepth => item.bitDepth;
|
||||||
|
int? get sampleRate => item.sampleRate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Track name
|
// Track name (from file metadata)
|
||||||
Text(
|
Text(
|
||||||
item.trackName,
|
trackName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Artist name
|
// Artist name (from file metadata)
|
||||||
Text(
|
Text(
|
||||||
item.artistName,
|
artistName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Album name
|
// Album name (from file metadata)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.albumName,
|
albumName,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -340,19 +353,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
// Metadata grid
|
// Metadata grid
|
||||||
_buildMetadataGrid(context, colorScheme),
|
_buildMetadataGrid(context, colorScheme),
|
||||||
|
|
||||||
// Spotify link button
|
// Streaming service link button
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
OutlinedButton.icon(
|
Builder(
|
||||||
onPressed: () => _openSpotifyUrl(context),
|
builder: (context) {
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
label: const Text('Open in Spotify'),
|
return OutlinedButton.icon(
|
||||||
style: OutlinedButton.styleFrom(
|
onPressed: () => _openServiceUrl(context),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
shape: RoundedRectangleBorder(
|
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
|
||||||
borderRadius: BorderRadius.circular(12),
|
style: OutlinedButton.styleFrom(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -361,16 +379,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
Future<void> _openServiceUrl(BuildContext context) async {
|
||||||
if (item.spotifyId == null) return;
|
if (item.spotifyId == null) return;
|
||||||
|
|
||||||
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
final rawId = item.spotifyId!.replaceAll('deezer:', '');
|
||||||
|
|
||||||
|
final webUrl = isDeezer
|
||||||
|
? 'https://www.deezer.com/track/$rawId'
|
||||||
|
: 'https://open.spotify.com/track/$rawId';
|
||||||
|
|
||||||
|
final appUri = isDeezer
|
||||||
|
? Uri.parse('deezer://www.deezer.com/track/$rawId')
|
||||||
|
: Uri.parse('spotify:track:$rawId');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to open in Spotify app first using URI scheme
|
// Try to open in App first using URI scheme
|
||||||
final launched = await launchUrl(
|
final launched = await launchUrl(
|
||||||
spotifyUri,
|
appUri,
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -393,7 +419,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
_copyToClipboard(context, webUrl);
|
_copyToClipboard(context, webUrl);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,31 +427,43 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
// Build audio quality string from file metadata
|
||||||
|
String? audioQualityStr;
|
||||||
|
if (bitDepth != null && sampleRate != null) {
|
||||||
|
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||||
|
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
|
||||||
|
}
|
||||||
|
|
||||||
final items = <_MetadataItem>[
|
final items = <_MetadataItem>[
|
||||||
_MetadataItem('Track name', item.trackName),
|
_MetadataItem('Track name', trackName),
|
||||||
_MetadataItem('Artist', item.artistName),
|
_MetadataItem('Artist', artistName),
|
||||||
if (item.albumArtist != null && item.albumArtist != item.artistName)
|
if (albumArtist != null && albumArtist != artistName)
|
||||||
_MetadataItem('Album artist', item.albumArtist!),
|
_MetadataItem('Album artist', albumArtist!),
|
||||||
_MetadataItem('Album', item.albumName),
|
_MetadataItem('Album', albumName),
|
||||||
if (item.trackNumber != null)
|
if (trackNumber != null && trackNumber! > 0)
|
||||||
_MetadataItem('Track number', item.trackNumber.toString()),
|
_MetadataItem('Track number', trackNumber.toString()),
|
||||||
if (item.discNumber != null && item.discNumber! > 1)
|
if (discNumber != null && discNumber! > 0)
|
||||||
_MetadataItem('Disc number', item.discNumber.toString()),
|
_MetadataItem('Disc number', discNumber.toString()),
|
||||||
if (item.duration != null)
|
if (item.duration != null)
|
||||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||||
if (item.quality != null && item.quality!.contains('bit'))
|
if (audioQualityStr != null)
|
||||||
_MetadataItem('Audio quality', item.quality!),
|
_MetadataItem('Audio quality', audioQualityStr),
|
||||||
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||||
_MetadataItem('Release date', item.releaseDate!),
|
_MetadataItem('Release date', releaseDate!),
|
||||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
if (isrc != null && isrc!.isNotEmpty)
|
||||||
_MetadataItem('ISRC', item.isrc!),
|
_MetadataItem('ISRC', isrc!),
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
];
|
||||||
_MetadataItem('Spotify ID', item.spotifyId!),
|
|
||||||
if (item.quality != null && item.quality!.isNotEmpty)
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||||
_MetadataItem('Quality', _formatQuality(item.quality!)),
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
|
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
|
||||||
|
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.addAll([
|
||||||
_MetadataItem('Service', item.service.toUpperCase()),
|
_MetadataItem('Service', item.service.toUpperCase()),
|
||||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||||
];
|
]);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: items.map((metadata) {
|
children: items.map((metadata) {
|
||||||
@@ -476,32 +514,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatQuality(String quality) {
|
|
||||||
switch (quality) {
|
|
||||||
case 'LOSSLESS':
|
|
||||||
return 'Lossless (16-bit)';
|
|
||||||
case 'HI_RES':
|
|
||||||
return 'Hi-Res (24-bit)';
|
|
||||||
case 'HI_RES_LOSSLESS':
|
|
||||||
return 'Hi-Res Lossless (24-bit)';
|
|
||||||
default:
|
|
||||||
return quality;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatQualityShort(String quality) {
|
|
||||||
switch (quality) {
|
|
||||||
case 'LOSSLESS':
|
|
||||||
return '16-bit';
|
|
||||||
case 'HI_RES':
|
|
||||||
return '24-bit';
|
|
||||||
case 'HI_RES_LOSSLESS':
|
|
||||||
return 'Hi-Res';
|
|
||||||
default:
|
|
||||||
return quality;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
||||||
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
||||||
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
||||||
@@ -570,7 +582,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.quality != null)
|
if (bitDepth != null && sampleRate != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -578,7 +590,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_formatQualityShort(item.quality!),
|
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -891,7 +903,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.delete, color: colorScheme.error),
|
leading: Icon(Icons.delete, color: colorScheme.error),
|
||||||
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)),
|
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_confirmDelete(context, ref, colorScheme);
|
_confirmDelete(context, ref, colorScheme);
|
||||||
@@ -908,10 +920,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Remove from history?'),
|
title: const Text('Remove from device?'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'This will remove the track from your download history. '
|
'This will permanently delete the downloaded file and remove it from your history.',
|
||||||
'The downloaded file will not be deleted.',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -919,12 +930,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
|
// Delete the file first
|
||||||
|
try {
|
||||||
|
final file = File(item.filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to delete file: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from history
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||||
Navigator.pop(context); // Close dialog
|
|
||||||
Navigator.pop(context); // Go back to history
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context); // Close dialog
|
||||||
|
Navigator.pop(context); // Go back to history
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
child: Text('Delete', style: TextStyle(color: colorScheme.error)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
@@ -133,22 +134,83 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed cover art to FLAC file
|
/// Embed metadata and cover art to FLAC file
|
||||||
/// Returns the file path on success, null on failure
|
/// Returns the file path on success, null on failure
|
||||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
static Future<String?> embedMetadata({
|
||||||
final tempOutput = '$flacPath.tmp';
|
required String flacPath,
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
|
||||||
|
// Use app-internal cache directory for temp output
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
||||||
|
|
||||||
|
// Construct command
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
|
// Add cover input if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map audio stream
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
// Map cover stream if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v copy ');
|
||||||
|
cmdBuffer.write('-disposition:v attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy audio codec (don't re-encode)
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
// Add text metadata
|
||||||
|
if (metadata != null) {
|
||||||
|
metadata.forEach((key, value) {
|
||||||
|
// Sanitize value: escape double quotes
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg command: $command');
|
||||||
|
|
||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
try {
|
try {
|
||||||
// Replace original with temp
|
// Copy temp output back to original location (replace)
|
||||||
await File(flacPath).delete();
|
final tempFile = File(tempOutput);
|
||||||
await File(tempOutput).rename(flacPath);
|
final originalFile = File(flacPath);
|
||||||
return flacPath;
|
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
// Delete original file
|
||||||
|
if (await originalFile.exists()) {
|
||||||
|
await originalFile.delete();
|
||||||
|
}
|
||||||
|
// Copy temp file to original location
|
||||||
|
await tempFile.copy(flacPath);
|
||||||
|
// Delete temp file
|
||||||
|
await tempFile.delete();
|
||||||
|
|
||||||
|
return flacPath;
|
||||||
|
} else {
|
||||||
|
_log.e('Temp output file not found: $tempOutput');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to replace file after cover embed: $e');
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +223,7 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
_log.e('Cover embed failed: ${result.output}');
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('PlatformBridge');
|
||||||
|
|
||||||
/// Bridge to communicate with Go backend via platform channels
|
/// Bridge to communicate with Go backend via platform channels
|
||||||
class PlatformBridge {
|
class PlatformBridge {
|
||||||
@@ -7,18 +10,21 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Parse and validate Spotify URL
|
/// Parse and validate Spotify URL
|
||||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||||
|
_log.d('parseSpotifyUrl: $url');
|
||||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Spotify metadata from URL
|
/// Get Spotify metadata from URL
|
||||||
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
||||||
|
_log.d('getSpotifyMetadata: $url');
|
||||||
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search Spotify
|
/// Search Spotify
|
||||||
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
||||||
|
_log.d('searchSpotify: "$query" (limit: $limit)');
|
||||||
final result = await _channel.invokeMethod('searchSpotify', {
|
final result = await _channel.invokeMethod('searchSpotify', {
|
||||||
'query': query,
|
'query': query,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
@@ -28,6 +34,7 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Search Spotify for tracks and artists
|
/// Search Spotify for tracks and artists
|
||||||
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||||
|
_log.d('searchSpotifyAll: "$query"');
|
||||||
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||||
'query': query,
|
'query': query,
|
||||||
'track_limit': trackLimit,
|
'track_limit': trackLimit,
|
||||||
@@ -38,6 +45,7 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Check track availability on streaming services
|
/// Check track availability on streaming services
|
||||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||||
|
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||||
final result = await _channel.invokeMethod('checkAvailability', {
|
final result = await _channel.invokeMethod('checkAvailability', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -67,6 +75,7 @@ class PlatformBridge {
|
|||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
_log.i('downloadTrack: "$trackName" by $artistName via $service');
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'service': service,
|
'service': service,
|
||||||
@@ -90,7 +99,13 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
if (response['success'] == true) {
|
||||||
|
_log.i('Download success: ${response['file_path']}');
|
||||||
|
} else {
|
||||||
|
_log.w('Download failed: ${response['error']}');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download with automatic fallback to other services
|
/// Download with automatic fallback to other services
|
||||||
@@ -111,10 +126,11 @@ class PlatformBridge {
|
|||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String preferredService = 'qobuz',
|
String preferredService = 'tidal',
|
||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'service': preferredService,
|
'service': preferredService,
|
||||||
@@ -138,7 +154,22 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
if (response['success'] == true) {
|
||||||
|
final service = response['service'] ?? 'unknown';
|
||||||
|
final filePath = response['file_path'] ?? '';
|
||||||
|
final bitDepth = response['actual_bit_depth'];
|
||||||
|
final sampleRate = response['actual_sample_rate'];
|
||||||
|
final qualityStr = bitDepth != null && sampleRate != null
|
||||||
|
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
|
||||||
|
: '';
|
||||||
|
_log.i('Download success via $service$qualityStr: $filePath');
|
||||||
|
} else {
|
||||||
|
final error = response['error'] ?? 'Unknown error';
|
||||||
|
final errorType = response['error_type'] ?? '';
|
||||||
|
_log.e('Download failed: $error (type: $errorType)');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get download progress (legacy single download)
|
/// Get download progress (legacy single download)
|
||||||
@@ -248,6 +279,16 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('cleanupConnections');
|
await _channel.invokeMethod('cleanupConnections');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read metadata directly from a FLAC file
|
||||||
|
/// Returns all embedded metadata (title, artist, album, track number, etc.)
|
||||||
|
/// This reads from the actual file, not from cached/database data
|
||||||
|
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
|
||||||
|
final result = await _channel.invokeMethod('readFileMetadata', {
|
||||||
|
'file_path': filePath,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Start foreground download service to keep downloads running in background
|
/// Start foreground download service to keep downloads running in background
|
||||||
static Future<void> startDownloadService({
|
static Future<void> startDownloadService({
|
||||||
String trackName = '',
|
String trackName = '',
|
||||||
@@ -335,6 +376,9 @@ class PlatformBridge {
|
|||||||
'resource_type': resourceType,
|
'resource_type': resourceType,
|
||||||
'resource_id': resourceId,
|
'resource_id': resourceId,
|
||||||
});
|
});
|
||||||
|
if (result == null) {
|
||||||
|
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
|
||||||
|
}
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,4 +408,35 @@ class PlatformBridge {
|
|||||||
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== GO BACKEND LOGS ====================
|
||||||
|
|
||||||
|
/// Get all logs from Go backend
|
||||||
|
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
||||||
|
final result = await _channel.invokeMethod('getLogs');
|
||||||
|
final logs = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return logs.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logs since a specific index (for incremental updates)
|
||||||
|
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
||||||
|
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear Go backend logs
|
||||||
|
static Future<void> clearGoLogs() async {
|
||||||
|
await _channel.invokeMethod('clearLogs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Go backend log count
|
||||||
|
static Future<int> getGoLogCount() async {
|
||||||
|
final result = await _channel.invokeMethod('getLogCount');
|
||||||
|
return result as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable Go backend logging
|
||||||
|
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||||
|
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ class AppTheme {
|
|||||||
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
||||||
|
|
||||||
/// Create light theme
|
/// Create light theme
|
||||||
static ThemeData light({
|
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
|
||||||
ColorScheme? dynamicScheme,
|
final scheme =
|
||||||
Color? seedColor,
|
dynamicScheme ??
|
||||||
}) {
|
|
||||||
final scheme = dynamicScheme ??
|
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? defaultSeedColor,
|
seedColor: seedColor ?? defaultSeedColor,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
@@ -45,7 +43,8 @@ class AppTheme {
|
|||||||
Color? seedColor,
|
Color? seedColor,
|
||||||
bool isAmoled = false,
|
bool isAmoled = false,
|
||||||
}) {
|
}) {
|
||||||
final scheme = dynamicScheme ??
|
final scheme =
|
||||||
|
dynamicScheme ??
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? defaultSeedColor,
|
seedColor: seedColor ?? defaultSeedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
@@ -75,34 +74,41 @@ class AppTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AppBar theme
|
/// AppBar theme
|
||||||
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
|
static AppBarTheme _appBarTheme(
|
||||||
elevation: 0,
|
ColorScheme scheme, {
|
||||||
scrolledUnderElevation: isAmoled ? 0 : 3,
|
bool isAmoled = false,
|
||||||
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
}) => AppBarTheme(
|
||||||
foregroundColor: scheme.onSurface,
|
elevation: 0,
|
||||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
scrolledUnderElevation: isAmoled ? 0 : 3,
|
||||||
centerTitle: true,
|
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
||||||
titleTextStyle: TextStyle(
|
foregroundColor: scheme.onSurface,
|
||||||
color: scheme.onSurface,
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
fontSize: 22,
|
centerTitle: true,
|
||||||
fontWeight: FontWeight.w500,
|
titleTextStyle: TextStyle(
|
||||||
),
|
color: scheme.onSurface,
|
||||||
);
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Card theme
|
/// Card theme
|
||||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
color: scheme.surfaceContainerLow,
|
borderRadius: BorderRadius.circular(16),
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
), // 12 -> 16
|
||||||
);
|
color: scheme.surfaceContainerLow,
|
||||||
|
surfaceTintColor: scheme.surfaceTint,
|
||||||
|
);
|
||||||
|
|
||||||
/// Elevated button theme
|
/// Elevated button theme
|
||||||
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
||||||
ElevatedButtonThemeData(
|
ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -111,7 +117,9 @@ class AppTheme {
|
|||||||
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
||||||
FilledButtonThemeData(
|
FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -120,7 +128,9 @@ class AppTheme {
|
|||||||
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
||||||
OutlinedButtonThemeData(
|
OutlinedButtonThemeData(
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -129,7 +139,9 @@ class AppTheme {
|
|||||||
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
||||||
TextButtonThemeData(
|
TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -147,52 +159,63 @@ class AppTheme {
|
|||||||
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
||||||
InputDecorationTheme(
|
InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: scheme.surfaceContainerHighest,
|
fillColor: scheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
), // Added transparency
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
), // consistent padding
|
||||||
);
|
);
|
||||||
|
|
||||||
/// List tile theme
|
/// List tile theme
|
||||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData(
|
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
ListTileThemeData(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 12 -> 16
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Dialog theme
|
/// Dialog theme
|
||||||
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
backgroundColor: scheme.surfaceContainerHigh,
|
backgroundColor: scheme.surfaceContainerHigh,
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
surfaceTintColor: scheme.surfaceTint,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Navigation bar theme
|
/// Navigation bar theme
|
||||||
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
|
static NavigationBarThemeData _navigationBarTheme(
|
||||||
NavigationBarThemeData(
|
ColorScheme scheme, {
|
||||||
elevation: 0,
|
bool isAmoled = false,
|
||||||
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
}) => NavigationBarThemeData(
|
||||||
indicatorColor: scheme.secondaryContainer,
|
elevation: 0,
|
||||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
||||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
indicatorColor: scheme.secondaryContainer,
|
||||||
);
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
|
);
|
||||||
|
|
||||||
/// SnackBar theme
|
/// SnackBar theme
|
||||||
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData(
|
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
|
||||||
|
SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
backgroundColor: scheme.inverseSurface,
|
backgroundColor: scheme.inverseSurface,
|
||||||
@@ -200,40 +223,44 @@ class AppTheme {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Progress indicator theme
|
/// Progress indicator theme
|
||||||
static ProgressIndicatorThemeData _progressIndicatorTheme(ColorScheme scheme) =>
|
static ProgressIndicatorThemeData _progressIndicatorTheme(
|
||||||
ProgressIndicatorThemeData(
|
ColorScheme scheme,
|
||||||
color: scheme.primary,
|
) => ProgressIndicatorThemeData(
|
||||||
linearTrackColor: scheme.surfaceContainerHighest,
|
color: scheme.primary,
|
||||||
circularTrackColor: scheme.surfaceContainerHighest,
|
linearTrackColor: scheme.surfaceContainerHighest,
|
||||||
);
|
circularTrackColor: scheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
/// Switch theme
|
/// Switch theme
|
||||||
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
||||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return scheme.onPrimary;
|
return scheme.onPrimary;
|
||||||
}
|
}
|
||||||
return scheme.outline;
|
return scheme.outline;
|
||||||
}),
|
}),
|
||||||
trackColor: WidgetStateProperty.resolveWith((states) {
|
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return scheme.primary;
|
return scheme.primary;
|
||||||
}
|
}
|
||||||
return scheme.surfaceContainerHighest;
|
return scheme.surfaceContainerHighest;
|
||||||
}),
|
}),
|
||||||
);
|
thumbIcon: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return Icon(Icons.check, color: scheme.primary);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/// Chip theme
|
/// Chip theme
|
||||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
backgroundColor: scheme.surfaceContainerLow,
|
backgroundColor: scheme.surfaceContainerLow,
|
||||||
selectedColor: scheme.secondaryContainer,
|
selectedColor: scheme.secondaryContainer,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Divider theme
|
/// Divider theme
|
||||||
static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(
|
static DividerThemeData _dividerTheme(ColorScheme scheme) =>
|
||||||
color: scheme.outlineVariant,
|
DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
|
||||||
thickness: 1,
|
|
||||||
space: 1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,234 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
|
/// Log entry with timestamp and level
|
||||||
|
class LogEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String level;
|
||||||
|
final String tag;
|
||||||
|
final String message;
|
||||||
|
final String? error;
|
||||||
|
final bool isFromGo; // Track if this log came from Go backend
|
||||||
|
|
||||||
|
LogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.level,
|
||||||
|
required this.tag,
|
||||||
|
required this.message,
|
||||||
|
this.error,
|
||||||
|
this.isFromGo = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get formattedTime {
|
||||||
|
final h = timestamp.hour.toString().padLeft(2, '0');
|
||||||
|
final m = timestamp.minute.toString().padLeft(2, '0');
|
||||||
|
final s = timestamp.second.toString().padLeft(2, '0');
|
||||||
|
final ms = timestamp.millisecond.toString().padLeft(3, '0');
|
||||||
|
return '$h:$m:$s.$ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final errorPart = error != null ? ' | $error' : '';
|
||||||
|
final goPart = isFromGo ? ' [Go]' : '';
|
||||||
|
return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circular buffer for storing logs in memory
|
||||||
|
class LogBuffer extends ChangeNotifier {
|
||||||
|
static final LogBuffer _instance = LogBuffer._internal();
|
||||||
|
factory LogBuffer() => _instance;
|
||||||
|
LogBuffer._internal();
|
||||||
|
|
||||||
|
static const int maxEntries = 500;
|
||||||
|
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||||
|
Timer? _goLogTimer;
|
||||||
|
int _lastGoLogIndex = 0;
|
||||||
|
|
||||||
|
/// Whether logging is enabled (controlled by settings)
|
||||||
|
/// User must enable "Detailed Logging" in settings to capture logs
|
||||||
|
static bool _loggingEnabled = false;
|
||||||
|
static bool get loggingEnabled => _loggingEnabled;
|
||||||
|
static set loggingEnabled(bool value) {
|
||||||
|
_loggingEnabled = value;
|
||||||
|
// Also notify Go backend about logging state
|
||||||
|
if (value) {
|
||||||
|
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
|
||||||
|
} else {
|
||||||
|
PlatformBridge.setGoLoggingEnabled(false).catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogEntry> get entries => _entries.toList();
|
||||||
|
int get length => _entries.length;
|
||||||
|
|
||||||
|
void add(LogEntry entry) {
|
||||||
|
// Skip adding if logging is disabled (except for errors which are always logged)
|
||||||
|
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entries.length >= maxEntries) {
|
||||||
|
_entries.removeFirst();
|
||||||
|
}
|
||||||
|
_entries.add(entry);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start polling Go backend logs
|
||||||
|
void startGoLogPolling() {
|
||||||
|
_goLogTimer?.cancel();
|
||||||
|
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||||
|
await _fetchGoLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop polling Go backend logs
|
||||||
|
void stopGoLogPolling() {
|
||||||
|
_goLogTimer?.cancel();
|
||||||
|
_goLogTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch logs from Go backend since last index
|
||||||
|
Future<void> _fetchGoLogs() async {
|
||||||
|
try {
|
||||||
|
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||||
|
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||||
|
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||||
|
|
||||||
|
for (final log in logs) {
|
||||||
|
final timestamp = log['timestamp'] as String? ?? '';
|
||||||
|
final level = log['level'] as String? ?? 'INFO';
|
||||||
|
final tag = log['tag'] as String? ?? 'Go';
|
||||||
|
final message = log['message'] as String? ?? '';
|
||||||
|
|
||||||
|
// Parse timestamp (format: "15:04:05.000")
|
||||||
|
DateTime parsedTime = DateTime.now();
|
||||||
|
if (timestamp.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final parts = timestamp.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
final secParts = parts[2].split('.');
|
||||||
|
parsedTime = DateTime(
|
||||||
|
parsedTime.year, parsedTime.month, parsedTime.day,
|
||||||
|
int.parse(parts[0]), int.parse(parts[1]),
|
||||||
|
int.parse(secParts[0]),
|
||||||
|
secParts.length > 1 ? int.parse(secParts[1]) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Use current time if parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(LogEntry(
|
||||||
|
timestamp: parsedTime,
|
||||||
|
level: level,
|
||||||
|
tag: tag,
|
||||||
|
message: message,
|
||||||
|
isFromGo: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastGoLogIndex = nextIndex;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors - Go backend might not be ready
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Failed to fetch Go logs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_entries.clear();
|
||||||
|
_lastGoLogIndex = 0;
|
||||||
|
// Also clear Go backend logs
|
||||||
|
PlatformBridge.clearGoLogs().catchError((_) {});
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String export() {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('SpotiFLAC Log Export');
|
||||||
|
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||||
|
buffer.writeln('Entries: ${_entries.length}');
|
||||||
|
buffer.writeln('=' * 60);
|
||||||
|
buffer.writeln();
|
||||||
|
for (final entry in _entries) {
|
||||||
|
buffer.writeln(entry.toString());
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
||||||
|
return _entries.where((entry) {
|
||||||
|
if (level != null && level != 'ALL' && entry.level != level) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (search != null && search.isNotEmpty) {
|
||||||
|
final searchLower = search.toLowerCase();
|
||||||
|
return entry.message.toLowerCase().contains(searchLower) ||
|
||||||
|
entry.tag.toLowerCase().contains(searchLower) ||
|
||||||
|
(entry.error?.toLowerCase().contains(searchLower) ?? false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom log output that writes to both console and buffer
|
||||||
|
class BufferedOutput extends LogOutput {
|
||||||
|
final String tag;
|
||||||
|
|
||||||
|
BufferedOutput(this.tag);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void output(OutputEvent event) {
|
||||||
|
// Print to console in debug mode
|
||||||
|
if (kDebugMode) {
|
||||||
|
for (final line in event.lines) {
|
||||||
|
debugPrint(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to buffer
|
||||||
|
final level = _levelToString(event.level);
|
||||||
|
final message = event.lines.join('\n');
|
||||||
|
|
||||||
|
LogBuffer().add(LogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: level,
|
||||||
|
tag: tag,
|
||||||
|
message: message,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _levelToString(Level level) {
|
||||||
|
switch (level) {
|
||||||
|
case Level.debug:
|
||||||
|
return 'DEBUG';
|
||||||
|
case Level.info:
|
||||||
|
return 'INFO';
|
||||||
|
case Level.warning:
|
||||||
|
return 'WARN';
|
||||||
|
case Level.error:
|
||||||
|
return 'ERROR';
|
||||||
|
case Level.fatal:
|
||||||
|
return 'FATAL';
|
||||||
|
default:
|
||||||
|
return 'LOG';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Global logger instance for the app
|
/// Global logger instance for the app
|
||||||
/// Uses pretty printer in debug mode for readable output
|
|
||||||
final log = Logger(
|
final log = Logger(
|
||||||
printer: PrettyPrinter(
|
printer: PrettyPrinter(
|
||||||
methodCount: 0,
|
methodCount: 0,
|
||||||
@@ -15,14 +242,76 @@ final log = Logger(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Logger with class/tag prefix for better traceability
|
/// Logger with class/tag prefix for better traceability
|
||||||
|
/// Now also writes to LogBuffer for in-app viewing
|
||||||
|
/// Works in both debug and release mode
|
||||||
class AppLogger {
|
class AppLogger {
|
||||||
final String _tag;
|
final String _tag;
|
||||||
|
late final Logger? _logger;
|
||||||
AppLogger(this._tag);
|
|
||||||
|
AppLogger(this._tag) {
|
||||||
void d(String message) => log.d('[$_tag] $message');
|
// Only create Logger instance in debug mode
|
||||||
void i(String message) => log.i('[$_tag] $message');
|
// In release mode, we write directly to LogBuffer
|
||||||
void w(String message) => log.w('[$_tag] $message');
|
if (kDebugMode) {
|
||||||
void e(String message, [Object? error, StackTrace? stackTrace]) =>
|
_logger = Logger(
|
||||||
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
|
printer: SimplePrinter(printTime: false, colors: false),
|
||||||
|
output: BufferedOutput(_tag),
|
||||||
|
level: Level.debug,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_logger = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addToBuffer(String level, String message, {String? error}) {
|
||||||
|
LogBuffer().add(LogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: level,
|
||||||
|
tag: _tag,
|
||||||
|
message: message,
|
||||||
|
error: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void d(String message) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.d(message);
|
||||||
|
} else {
|
||||||
|
// In release mode, write directly to buffer
|
||||||
|
_addToBuffer('DEBUG', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void i(String message) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.i(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('INFO', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void w(String message) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.w(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('WARN', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
|
if (error != null) {
|
||||||
|
_addToBuffer('ERROR', message, error: error.toString());
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[$_tag] ERROR: $message | $error');
|
||||||
|
if (stackTrace != null) {
|
||||||
|
debugPrint(stackTrace.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (kDebugMode) {
|
||||||
|
_logger?.e(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('ERROR', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: "none"
|
||||||
version: 2.1.5+43
|
version: 2.2.9+51
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -9,51 +9,51 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
riverpod_annotation: ^4.0.0
|
riverpod_annotation: ^4.0.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
|
|
||||||
# Storage & Persistence
|
# Storage & Persistence
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.4.0
|
http: ^1.4.0
|
||||||
dio: ^5.8.0
|
dio: ^5.8.0
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
flutter_svg: ^2.1.0
|
flutter_svg: ^2.1.0
|
||||||
|
|
||||||
# Material Expressive 3 / Dynamic Color
|
# Material Expressive 3 / Dynamic Color
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
material_color_utilities: ^0.11.1
|
material_color_utilities: ^0.11.1
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
# File Picker
|
# File Picker
|
||||||
file_picker: ^10.3.0
|
file_picker: ^10.3.0
|
||||||
|
|
||||||
# JSON Serialization
|
# JSON Serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
device_info_plus: ^12.3.0
|
device_info_plus: ^12.3.0
|
||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
logger: ^2.5.0
|
logger: ^2.5.0
|
||||||
|
|
||||||
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
||||||
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.0.0
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
@@ -77,6 +77,6 @@ flutter_launcher_icons:
|
|||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: "none"
|
||||||
version: 2.1.0-preview2+40
|
version: 2.2.9+51
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -9,51 +9,51 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
riverpod_annotation: ^4.0.0
|
riverpod_annotation: ^4.0.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
|
|
||||||
# Storage & Persistence
|
# Storage & Persistence
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.4.0
|
http: ^1.4.0
|
||||||
dio: ^5.8.0
|
dio: ^5.8.0
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
flutter_svg: ^2.1.0
|
flutter_svg: ^2.1.0
|
||||||
|
|
||||||
# Material Expressive 3 / Dynamic Color
|
# Material Expressive 3 / Dynamic Color
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
material_color_utilities: ^0.11.1
|
material_color_utilities: ^0.11.1
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
# File Picker
|
# File Picker
|
||||||
file_picker: ^10.3.0
|
file_picker: ^10.3.0
|
||||||
|
|
||||||
# JSON Serialization
|
# JSON Serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
device_info_plus: ^12.3.0
|
device_info_plus: ^12.3.0
|
||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
logger: ^2.5.0
|
logger: ^2.5.0
|
||||||
|
|
||||||
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
|
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
|
||||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.0.0
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
@@ -77,6 +77,6 @@ flutter_launcher_icons:
|
|||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|||||||