Compare commits

...

49 Commits

Author SHA1 Message Date
zarzet c673581c32 feat: multi-select batch delete and album grouping in history
- Add multi-select mode with long-press to select tracks
- Add bottom action bar for selection (Material 3 style)
- Add filter tabs: All/Albums/Singles
- Add album grouping view when Albums filter selected
- Add DownloadedAlbumScreen for viewing tracks in an album
- Reactive UI updates when tracks deleted
- Auto-pop when album has <2 tracks
- Update issue templates with (Stable Version) text
- Bump version to 2.2.8
2026-01-12 06:18:32 +07:00
zarzet a6d488696b chore: add extension API feature request template and ignore docs folder 2026-01-12 01:22:23 +07:00
zarzet 24ef66be4c Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-01-11 06:41:54 +07:00
zarzet d07a49f605 UI modernization: Global theme update, redesigned Options/Download settings, and smart filename editor 2026-01-11 06:41:34 +07:00
zarzet 4eba28db7a v2.2.7: CSV import metadata enrichment with Deezer fallback 2026-01-11 06:09:48 +07:00
zarzet b73a3f8912 Add CSV import and optimize Appearance settings 2026-01-11 05:56:30 +07:00
zarzet 9f47f2ce85 UI Modernization: Unified app bars, updated logos, improved settings & Deezer support 2026-01-11 04:28:41 +07:00
zarzet f2aca734a3 fix: improve logging for release builds and UI improvements
- Fix Flutter logs not appearing in release mode by bypassing Logger package
- Add detailed logging for Deezer search API calls
- Replace music_note icon with app logo on home screen
- Remove shadow/border from logo in About and Home screens
- Align icon size (40x40) with avatar in About page for consistent layout
2026-01-11 02:27:26 +07:00
Zarz Eleutherius 09cb637a86 Update VirusTotal link in README.md 2026-01-10 19:27:48 +07:00
zarzet 11e7034cec v2.2.5: In-app logging, ISP blocking detection, Latin script fix 2026-01-10 19:03:39 +07:00
zarzet f12c18d76b Add issue templates, update workflow SDK, fix about logo 2026-01-10 16:53:41 +07:00
zarzet 0da39a1b8b chore: update NDK to r27d LTS (27.3.13750724) for 16KB page size support 2026-01-10 16:48:52 +07:00
Zarz Eleutherius f29fe5054c Updated VirusTotal badge link in README
Updated VirusTotal badge link to the latest report.
2026-01-10 16:09:39 +07:00
zarzet c8c0164964 chore: update targetSdk to 36 (Android 16) with timeout handler 2026-01-10 04:51:15 +07:00
zarzet 52dd657913 fix: improve search result parsing robustness for edge cases 2026-01-10 04:48:32 +07:00
zarzet c30f9fe412 fix(ios): add setSpotifyCredentials method to AppDelegate 2026-01-10 04:36:28 +07:00
zarzet bea5dd1d4a v2.2.0: Default to Tidal, faster ISRC matching, ISRC enrichment for search 2026-01-10 04:33:05 +07:00
Zarz Eleutherius 8726a0858a Update VirusTotal link in README.md 2026-01-09 19:03:36 +07:00
zarzet 74bc747599 chore: update to v2.1.7 with new icons 2026-01-09 17:54:50 +07:00
zarzet cbc8fdcb0c feat: add download badges to release page 2026-01-09 17:25:01 +07:00
zarzet 3b79b4f1ca fix: use absolute path for IPA creation 2026-01-09 02:41:19 +07:00
zarzet 5692a76650 Fix: iOS embedMetadata method + Android APK path detection 2026-01-09 02:31:01 +07:00
zarzet 7a009ad0af Fix R8: add dontwarn for Play Core and javax.xml.stream 2026-01-09 02:19:37 +07:00
zarzet e5e75e7092 Fix iOS build: use xcodebuild with CODE_SIGNING_ALLOWED=NO 2026-01-09 02:18:54 +07:00
zarzet 01b8fd2480 Fix metadata consistency (Go->Flutter) and build optimization
- Backend: Return full metadata (Track, Disc, Year) from Tidal/Qobuz/Amazon download results
- Flutter: Use backend metadata for tagging converted M4A and history entries
- Fix: Duplicate convertTrack method in deezer.go
- Fix: Better error message for Deezer fallback failure
- Changed: Default service fallback to Tidal -> Qobuz -> Amazon
- Build: Re-enabled resource shrinking and minification for release build
2026-01-09 02:12:24 +07:00
Zarz Eleutherius ee807a44cc Update instructions for using Spotify API 2026-01-08 13:36:10 +07:00
Zarz Eleutherius c9b905eb18 Update VirusTotal badge link in README.md 2026-01-08 01:08:32 +07:00
zarzet e9c7bf830e Update changelog for v2.1.5 2026-01-08 00:53:38 +07:00
zarzet 8bc97d5bd3 v2.1.5: Deezer API 2.0, Qobuz default, fetch ISRC for search results 2026-01-08 00:52:24 +07:00
zarzet f2c241c323 Fix .tmp permission issue on Android Music folder 2026-01-08 00:26:49 +07:00
zarzet 9c512ffe28 Add migration for Deezer default (skip if custom Spotify enabled) 2026-01-07 23:19:04 +07:00
zarzet 53a1da6249 v2.1.5: Fix progress bar and incomplete downloads
- Fix progress bar jumping from 1% to 100% (threshold-based updates)
- Fix incomplete downloads with temp file + size validation
- Applies to Tidal, Qobuz, and Amazon services
2026-01-07 23:15:48 +07:00
Zarz Eleutherius d4274e8ca8 Include setup instructions for Spotify API usage
Added detailed instructions for setting up Spotify as a search source, including steps for creating a developer account and entering credentials.
2026-01-07 13:55:31 +07:00
Zarz Eleutherius 49a9f12841 Simplify README by removing Spotify setup details
Removed detailed instructions for using Spotify and support section.
2026-01-07 13:53:30 +07:00
zarzet d7fa040e3c fix: Deezer artist/album screen improvements
- Fix album_screen to support Deezer album IDs (deezer:xxx format)
- Use getSpotifyMetadataWithFallback for Spotify albums
- Hide track count in artist discography when not available from Deezer API
- Deezer /artist/{id}/albums endpoint doesn't return nb_tracks
2026-01-07 04:25:35 +07:00
zarzet 9baa1e2088 fix: Replace android-actions/setup-android with direct SDK setup 2026-01-07 03:38:00 +07:00
zarzet 482457205a feat: Add Deezer as alternative metadata source with auto-fallback
- Add Deezer API client (no auth required, rate limit per user IP)
- Add search source selector in Settings (Deezer/Spotify)
- Default to Deezer for better reliability
- Auto-fallback to Deezer when Spotify API is rate limited (429)
- Support fallback for tracks and albums via SongLink API
- Update README with metadata source documentation
- Version 2.1.5-preview (build 42)
2026-01-07 03:25:14 +07:00
zarzet 3b2ec319e2 Fix pubspec version to 2.1.0+41 2026-01-07 00:19:53 +07:00
zarzet a0f7e75a9a Update VirusTotal badge to 2.1.0 2026-01-07 00:15:43 +07:00
zarzet c725e53e4c Release 2.1.0 2026-01-07 00:00:28 +07:00
zarzet 1d7c43a302 v2.1.0-preview: Download speed optimizations 2026-01-06 03:56:26 +07:00
Zarz Eleutherius df7c1c5bb7 Add VirusTotal badge to README
Added VirusTotal badge to README for safety verification.
2026-01-06 02:05:50 +07:00
zarzet bb05353b7e fix(ios): directory picker - add App Documents option for iOS
- iOS limitation: empty folders cannot be selected via document picker
- Added bottom sheet with App Documents Folder as recommended option
- Shows info message explaining iOS limitation
- Files accessible via iOS Files app

Version: 2.0.7-preview2+38
2026-01-06 00:23:19 +07:00
zarzet 7ac92d77e5 chore: add custom FFmpeg AAR to repo for CI builds 2026-01-05 14:23:41 +07:00
zarzet cf00ecb756 feat: use custom FFmpeg AAR for Android, reduce APK size
- Replace ffmpeg_kit_flutter plugin with custom AAR (arm64 + arm7a only)
- Add MethodChannel bridge for FFmpeg in MainActivity
- Create separate pubspec_ios.yaml for iOS builds with ffmpeg_kit plugin
- Update GitHub workflow to swap pubspec for iOS builds
- Reduces Android APK size by ~50MB
2026-01-05 14:09:32 +07:00
zarzet 525f2fd0cd chore: bump version to 2.0.6+36 2026-01-05 12:21:05 +07:00
zarzet 3e841cef06 fix: duration display, audio quality from file, artist verification, metadata case-sensitivity, settings navigation freeze
- Fix duration showing incorrect values (ms to seconds conversion)
- Read audio quality from FLAC file instead of trusting API
- Add artist verification for Tidal/Qobuz/Amazon downloads
- Fix FLAC metadata case-insensitive replacement
- Fix settings navigation freeze on Android 14+ (PopScope handling)
2026-01-05 10:30:57 +07:00
zarzet a8527df80a docs: application stabilized, remove dev notice 2026-01-05 03:12:30 +07:00
zarzet 51b2ad5c77 v2.0.5: Large playlist support + duration verification fix
- Add pagination for playlists (up to 1000 tracks)
- Add duration verification to prevent wrong track downloads
- When Tidal returns wrong version, fallback to Qobuz/Amazon
2026-01-05 03:08:15 +07:00
110 changed files with 14865 additions and 2543 deletions
+123
View File
@@ -0,0 +1,123 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have searched existing issues and this bug hasn't been reported yet
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what you expected...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
validations:
required: true
- type: input
id: version
attributes:
label: App Version
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
placeholder: "e.g., v2.2.0"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Which platform are you using?
options:
- Android
- iOS
validations:
required: true
- type: input
id: device
attributes:
label: Device & OS Version
description: What device and OS version are you using?
placeholder: "e.g., Samsung Galaxy S24, Android 14"
validations:
required: true
- type: dropdown
id: download-service
attributes:
label: Download Service
description: Which download service were you using when the bug occurred?
options:
- Tidal
- Qobuz
- Amazon Music
- Deezer (search only)
- Not applicable
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: |
If applicable, add logs or screenshots to help explain your problem.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Reproduce the bug
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Paste logs or drag & drop screenshots here...
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context about the problem
placeholder: Add any other context...
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: README
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
about: Check the README for setup instructions and FAQ
- name: Extension Development Guide
url: https://zarz.moe/docs
about: Documentation for building SpotiFLAC extensions
+111
View File
@@ -0,0 +1,111 @@
name: Download Issue
description: Report issues with downloading specific tracks or albums
title: "[Download]: "
labels: ["download-issue"]
body:
- type: markdown
attributes:
value: |
Having trouble downloading a specific track or album? Please provide details below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
required: true
- label: I am using the latest version of SpotiFLAC (Stable Version)
required: true
- type: dropdown
id: issue-type
attributes:
label: Issue Type
description: What kind of download issue are you experiencing?
options:
- Track not found on service
- Wrong track downloaded
- Download fails/errors
- Metadata incorrect
- Audio quality issue
- Other
validations:
required: true
- type: input
id: spotify-url
attributes:
label: Spotify URL
description: The Spotify URL of the track/album you're trying to download
placeholder: "https://open.spotify.com/track/..."
validations:
required: true
- type: input
id: track-info
attributes:
label: Track Info
description: Artist name and track title
placeholder: "Artist - Track Title"
validations:
required: true
- type: dropdown
id: download-service
attributes:
label: Download Service
description: Which service did you try to download from?
options:
- Tidal
- Qobuz
- Amazon Music
- All services
validations:
required: true
- type: dropdown
id: search-service
attributes:
label: Search Service
description: Which search service are you using?
options:
- Spotify
- Deezer
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe the issue in detail
placeholder: |
What happened? What did you expect?
If wrong track was downloaded, what track was downloaded instead?
validations:
required: true
- type: input
id: version
attributes:
label: App Version
description: Which version of SpotiFLAC are you using?
placeholder: "e.g., v2.2.0"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots / Logs
description: |
If applicable, add screenshots or logs.
**To get logs:**
1. Go to Settings > Options > Detailed Logging (turn ON)
2. Try downloading the track again
3. Go to Settings > Logs
4. Tap Share button to export logs
placeholder: Drag & drop screenshots or paste logs here...
@@ -0,0 +1,117 @@
name: Extension API Feature Request (Alpha)
description: Request new API features or capabilities for extension development (Extension system is in alpha)
title: "[Extension API]: "
labels: ["enhancement", "extension-api"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the SpotiFLAC Extension API!
This form is for extension developers who need new features or capabilities that don't exist yet.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have read the [Extension Development Guide](https://zarz.moe/docs)
required: true
- label: I have searched existing issues and this API feature hasn't been requested yet
required: true
- type: textarea
id: extension_goal
attributes:
label: What are you trying to build?
description: Describe the extension or feature you're developing
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
validations:
required: true
- type: textarea
id: current_limitation
attributes:
label: Current API Limitation
description: What's missing or limiting in the current extension API?
placeholder: |
The current API doesn't support:
- [missing feature 1]
- [missing feature 2]
This prevents me from...
validations:
required: true
- type: textarea
id: proposed_api
attributes:
label: Proposed API / Feature
description: Describe the API or feature you'd like to see added
placeholder: |
I would like to have:
- A new function `api.newFeature()` that does X
- A new manifest field `newOption` that enables Y
- Access to Z capability...
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use Case Example
description: Provide a code example of how you would use this feature
placeholder: |
```javascript
// Example usage in extension code
function download(request, progressCallback) {
const result = api.proposedFeature(params);
// ...
}
```
validations:
required: false
- type: dropdown
id: api_category
attributes:
label: API Category
description: What category does this feature fall under?
options:
- HTTP/Network API
- File System API
- Storage API
- FFmpeg/Audio Processing
- Manifest Options
- Runtime Functions
- UI Integration
- Authentication
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How critical is this for your extension?
options:
- Blocker - Cannot build my extension without this
- High - Major functionality depends on this
- Medium - Would significantly improve my extension
- Low - Nice to have
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Current Workaround
description: Are you using any workaround currently? If so, describe it.
placeholder: "Currently I'm working around this by..."
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, links to similar APIs, or examples from other platforms
placeholder: "Similar feature in other platforms: ..."
@@ -0,0 +1,66 @@
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please fill out the form below.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have searched existing issues and this feature hasn't been requested yet
required: true
- type: textarea
id: problem
attributes:
label: Problem / Motivation
description: Is your feature request related to a problem? Please describe.
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like
placeholder: A clear description of what you want to happen...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or features you've considered
placeholder: Other approaches you've thought about...
- type: dropdown
id: category
attributes:
label: Category
description: What category does this feature fall under?
options:
- UI/UX Improvement
- Download Feature
- New Service Integration
- Metadata/Tagging
- Performance
- Settings/Configuration
- Other
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request
placeholder: Add any other context or screenshots...
+113 -48
View File
@@ -3,13 +3,13 @@ name: Release
on:
push:
tags:
- 'v*'
- "v*"
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
description: "Version tag (e.g., v1.0.0)"
required: true
default: 'v1.0.0'
default: "v1.0.0"
jobs:
# Get version first (quick job)
@@ -28,7 +28,7 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
@@ -43,7 +43,7 @@ jobs:
build-android:
runs-on: ubuntu-latest
needs: get-version
steps:
- name: Free disk space
run: |
@@ -65,13 +65,13 @@ jobs:
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
distribution: "temurin"
java-version: "17"
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: "1.21"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -85,7 +85,20 @@ jobs:
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK
uses: android-actions/setup-android@v3
run: |
# Use pre-installed Android SDK on GitHub runners
echo "ANDROID_HOME=$ANDROID_HOME"
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
# Accept licenses
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
# Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
- name: Install gomobile
run: |
@@ -103,7 +116,7 @@ jobs:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
channel: "stable"
cache: true
- name: Get Flutter dependencies
@@ -113,7 +126,14 @@ jobs:
run: dart run flutter_launcher_icons
- 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
uses: r0adkll/sign-android-release@v1
@@ -125,7 +145,7 @@ jobs:
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "34.0.0"
BUILD_TOOLS_VERSION: "36.0.0"
- name: Rename APKs
run: |
@@ -145,8 +165,8 @@ jobs:
build-ios:
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:
- name: Checkout repository
uses: actions/checkout@v4
@@ -154,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: "1.21"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
@@ -182,53 +202,70 @@ jobs:
run: |
ls -la ios/Frameworks/
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
- name: Add XCFramework to Xcode project
run: |
# Install xcodeproj gem for modifying Xcode project
sudo gem install xcodeproj
# Create Ruby script to add framework
cat > add_framework.rb << 'EOF'
require 'xcodeproj'
project_path = 'ios/Runner.xcodeproj'
project = Xcodeproj::Project.open(project_path)
# Get the main target
target = project.targets.find { |t| t.name == 'Runner' }
# Get or create Frameworks group
frameworks_group = project.main_group.find_subpath('Frameworks', true)
frameworks_group ||= project.main_group.new_group('Frameworks')
# Add XCFramework reference
framework_path = 'Frameworks/Gobackend.xcframework'
framework_ref = frameworks_group.new_file(framework_path, :project)
# Add to frameworks build phase
frameworks_build_phase = target.frameworks_build_phase
frameworks_build_phase.add_file_reference(framework_ref)
# Add to embed frameworks build phase
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
if embed_phase
build_file = embed_phase.add_file_reference(framework_ref)
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
end
project.save
puts "Successfully added Gobackend.xcframework to Xcode project"
EOF
ruby add_framework.rb
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
channel: "stable"
cache: true
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
- name: Use iOS pubspec with FFmpeg plugin
run: |
cp pubspec.yaml pubspec_android_backup.yaml
cp pubspec_ios.yaml pubspec.yaml
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
# Swap FFmpeg service for iOS
- name: Use iOS FFmpeg service
run: |
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
# Update class name in the swapped file
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
echo "Swapped to iOS FFmpeg service"
- name: Get Flutter dependencies
run: flutter pub get
@@ -236,18 +273,44 @@ jobs:
run: dart run flutter_launcher_icons
- 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
run: |
VERSION=${{ needs.get-version.outputs.version }}
mkdir -p build/ios/ipa
cd build/ios/iphoneos
cd ios/build/Runner.xcarchive/Products/Applications
mkdir 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
- 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
uses: actions/upload-artifact@v4
with:
@@ -259,7 +322,7 @@ jobs:
needs: [get-version, build-android, build-ios]
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -269,13 +332,13 @@ jobs:
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
echo "Looking for version: $VERSION_NUM"
# Extract changelog section for this version using sed
# 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)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
echo "No changelog found for version $VERSION_NUM"
@@ -283,7 +346,7 @@ jobs:
else
echo "Found changelog content"
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
@@ -305,32 +368,34 @@ jobs:
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
## SpotiFLAC $VERSION
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
### What's New
HEADER
# Replace $VERSION in header
sed -i "s/\$VERSION/$VERSION/g" /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
---
### 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)
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER
echo "Release body:"
cat /tmp/release_body.txt
+4 -3
View File
@@ -13,8 +13,8 @@ Thumbs.db
# Reference folder (development only)
referensi/
# Development notes
COMPARISON_PC_vs_ANDROID.md
# Documentation (hosted separately)
docs/
# Old spotiflac_android folder (moved to root)
spotiflac_android/
@@ -38,7 +38,7 @@ go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/
android/app/libs/gobackend.aar
android/local.properties
android/*.iml
android/key.properties
@@ -52,3 +52,4 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
+596 -2
View File
@@ -1,8 +1,551 @@
# Changelog
## [2.2.8] - 2026-01-12
### Added
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
- Select multiple tracks at once
- "Select All" and "Delete Selected" actions
- Modern Material 3 bottom action bar (slides up from bottom)
- Works in both grid and list view modes
- **History Filter Tabs**: Filter history by All/Albums/Singles
- Album = tracks where album has >1 track in history
- Single = tracks where album has only 1 track in history
- Filter chips show counts for each category
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
- Album cards displayed in 2-column grid with cover art and track count badge
- Tap album to open dedicated album detail screen
- Album detail shows all downloaded tracks from that album
- Multi-select delete support within album view
- Auto-navigates back when album has <2 tracks remaining
### Changed
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
## [2.2.7] - 2026-01-11
### Added
- **CSV Import Metadata Enrichment**: Tracks imported from CSV now automatically fetch metadata from Deezer
- Cover art, duration, track/disc number fetched via ISRC lookup
- Fallback to text search (artist + track name) when ISRC not found in Deezer
- Progress dialog shows enrichment status during import
- Ensures downloaded files have proper cover art and metadata
- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks
- "Open in Deezer" button for Deezer-sourced tracks (opens app or web)
- Displays "Deezer ID" instead of "Spotify ID" when applicable
- **Smart Tag Injection**: Filename format editor intelligently handles separators
- Auto-detects if " - " is needed between tags
- Prevents double separators or missing spaces
- **Dynamic Source Info**: Search source selector now shows helpful context
- "No login required" for Deezer
- "Requires credentials" for Spotify
### Changed
- **UI Modernization**: Major UI consistency updates across the app
- **Unified App Bars**: Home, History, and Settings now share identical behavior
- Lowered expanded header for easier one-handed reachability
- Dynamic title text scaling (20px to 34px)
- **Appearance Settings**: Completely redesigned appearance page
- New "Theme Preview" card showing visualizing current theme
- Modern color palette picker replacing old color dots
- Clean, grouped layout
- "AMOLED Dark" switch is now hidden when using Light Mode
- **App Logo**: Refined logo style on Home and About screens
- Inverted colors: Filled primary color circle with on-color icon
- Removed padding for a cleaner, bolder look
- **Material 3 Switches**: Added checkmark icon to active switches
- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look
- **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields
- **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest`
- **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius)
- **Options Settings Redesign**: improved layout and usability
- **Search Source Priority**: Moved "Search Source" section to the very top for quick access
- **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent
- **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing
- **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret
- **Filename Format Editor 2.0**:
- **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet
- **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion
- **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing
### Fixed
- **CSV Import Missing Cover Art**: Fixed tracks from CSV having no cover art in download history
- Cover URL now properly fetched from Deezer during enrichment
- Falls back to text search when ISRC lookup fails
- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks
- Duration now fetched from Deezer metadata during enrichment
- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen
- Changed condition from `discNumber > 0` to `discNumber > 0`
- Now displays disc 1 instead of hiding it
- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data
- Now uses `trackToDownload` (enriched) instead of `item.track` (original)
### Technical
- Updated `lib/services/csv_import_service.dart`:
- Added `_enrichTracksMetadata()` with ISRC lookup + text search fallback
- Added progress callback for UI feedback
- Updated `lib/screens/home_tab.dart`:
- Added progress dialog during CSV enrichment
- Updated `lib/providers/download_queue_provider.dart`:
- Uses enriched track data for download history
- Updated `lib/screens/track_metadata_screen.dart`:
- Show disc number when > 0 (was > 1)
- Updated `go_backend/metadata.go`:
- Added `TotalSamples` to `AudioQuality` struct for duration calculation
- Updated `go_backend/exports.go`:
- `ReadFileMetadata` now returns duration calculated from FLAC stream info
- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions
- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities
- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling
---
## [2.2.6] - 2026-01-11
### Fixed
- **Release Mode Logging**: Flutter app logs now properly captured in release builds
- Previously only Go backend logs appeared when "Detailed Logging" was enabled
- Now both Flutter and Go logs are captured in release mode
- Bypasses Logger package which filters logs in release mode
### Added
- **Detailed Deezer Search Logging**: Better debugging for search issues
- Logs API URLs, response counts, and errors
- Helps diagnose geo-restriction and API issues
- Detects Deezer API error responses
### Changed
- **Home Screen Logo**: Replaced music note icon with app logo
- Uses `assets/images/logo.png`
- Rounded corners (24px radius)
- Fallback to music note icon if logo fails to load
- **About Page Logo**: Removed shadow/border from logo
- Cleaner appearance without background container
- **About Page Icon Alignment**: Icons now aligned with contributor avatars
- DoubleDouble and DAB Music icons use 40x40 area
- Text now properly aligned with contributor items
## [2.2.5] - 2026-01-10
### Added
- **In-App Log Viewer with Go Backend Logs**: Complete logging system for debugging
- Go backend logs now captured and displayed in app
- Circular buffer stores up to 500 log entries
- Real-time polling (500ms) for Go backend logs
- Logs include timestamp, level, tag, and message
- "Go" badge indicates logs from backend
- **Detailed Logging Toggle**: Control logging in Settings > Options > Debug
- Disabled by default for performance
- Errors are always logged regardless of setting
- Enable before reproducing bugs for detailed logs
- **Log Issue Summary**: Automatic detection of common issues in logs
- ISP Blocking detection with affected domains
- Rate limiting detection
- Network error detection
- Track not found detection
- Shows suggestions for each issue type
- **ISP Blocking Detection**: Detects when ISP blocks download services
- DNS resolution failure detection
- Connection reset/refused detection
- TLS handshake failure detection
- HTTP 403/451 blocking page detection
- Suggests VPN or DNS change (1.1.1.1 / 8.8.8.8)
### Fixed
- **Artist Profile Placeholder**: Shows person icon when artist has no profile image
- Validates image URL before loading
- Fallback icon on load error
- **Latin Extended Character Detection**: Fixed wrong track downloads for Polish, Czech, French, Spanish songs
- Characters like Ł, ę, ć, ñ, é now correctly treated as Latin script
- Previously treated as "different script" causing false matches
- Affects both Tidal and Qobuz search
### Changed
- **Log Screen UI Improvements**:
- Copy button moved to app bar (left of menu)
- Removed redundant info card
- Cleaner interface
- **Issue Templates Updated**: Instructions for enabling detailed logging before submitting bug reports
### Technical
- New file: `go_backend/logbuffer.go` with circular buffer and GoLog function
- Updated `go_backend/httputil.go` with ISP blocking detection
- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with `isLatinScript()` function
- Updated `lib/utils/logger.dart` with Go log polling
- Updated `lib/screens/settings/log_screen.dart` with issue summary
- Added method channel handlers for logging in Android and iOS
- New error type: `isp_blocked` for ISP blocking errors
---
## [2.2.0] - 2026-01-10
### Fixed
- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application.
- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request.
- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames.
- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes.
- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error
- Added proper null checks when parsing Go backend response
- Added type checking for track data before parsing
- **Duration Calculation in Enrichment**: Fixed duration conversion bug
- Go backend returns `duration_ms` (milliseconds)
- Now properly converts to seconds for Track model
### Changed
- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**.
- Tidal is now the default download service (was Qobuz)
- Tidal has faster and more reliable ISRC matching
- Existing users need to change setting manually or clear app data
- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source.
### Added
- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks.
- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy.
- Returns as soon as ISRC match is found in first query results
- Significantly faster for tracks with valid ISRC
- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download
- Search results don't include ISRC (for performance)
- ISRC is now fetched via metadata enrichment when download starts
- Ensures accurate track matching on all streaming services
- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism.
- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging
- Shows when ISRC search is attempted
- Shows number of results and exact ISRC matches found
### Technical
- Updated `go_backend/tidal.go`:
- Early exit optimization in `SearchTrackByMetadataWithISRC()`
- Deezer ID support in SongLink lookup
- Updated `go_backend/qobuz.go`:
- Added logging for ISRC search flow
- Duration tolerance reduced from 30s to 10s
- Updated `go_backend/exports.go`:
- Default service order changed to `[tidal, qobuz, amazon]`
- Updated `lib/providers/download_queue_provider.dart`:
- ISRC-based enrichment condition
- Null-safe parsing of Go backend response
- Updated `lib/services/platform_bridge.dart`:
- Null check for `getDeezerMetadata` result
- Updated `lib/models/settings.dart`:
- Default service changed to `tidal`
---
## [2.1.7] - 2026-01-09
### Added
- **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
- Configure in Settings > Options > Spotify API > Search Source
- Default is Deezer for better reliability
- 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
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
- Fetches metadata from Deezer instead
### Changed
- **Default Download Service**: Changed from Tidal to Qobuz
- Fallback order is now: Qobuz → Tidal → Amazon
- **Deezer API Updated to v2.0**: More reliable and complete metadata
- Direct ISRC lookup via `/track/isrc:{ISRC}` endpoint
- Search results now fetch full track info to include ISRC
### Fixed
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
- Progress now updates smoothly every 64KB of data received
- First progress update happens immediately when download starts
- **Incomplete Downloads**: Fixed bug where interrupted downloads could result in corrupted/incomplete files
- File size is validated against server's Content-Length header
- Incomplete files are automatically deleted and error is reported
- Applies to all services: Tidal, Qobuz, and Amazon
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
### Technical
- Settings migration for existing users to set Deezer as default metadata source
---
## [2.1.5] - 2026-01-08
### Added
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
- Change service on-the-fly without going to settings
- Available in Home, Album, and Playlist screens
- **AMOLED Dark Theme**: Pure black background for OLED screens
- Toggle in Settings > Appearance > Theme
- Saves battery on OLED/AMOLED displays
- All surface colors adjusted for true black background
- **Update Channel Setting**: Choose between Stable and Preview release channels
- Stable: Only receive stable release notifications
- Preview: Get notified about preview/beta releases too
- Configure in Settings > Options > App
### Changed
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
- Only includes FLAC, MP3 (LAME), and AAC codecs
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- Native MethodChannel bridge for FFmpeg operations
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
### Fixed
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- Shows "Lyrics not available" instead of loading forever
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- iOS limitation: Empty folders cannot be selected via document picker
- Added "App Documents Folder" option as recommended default
- Files saved to app Documents folder are accessible via iOS Files app
### Performance
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
- ISRC search first strategy (faster than SongLink API)
- Track ID cache with 30 minute TTL for album/playlist downloads
- Pre-warm cache when viewing album/playlist
- Parallel cover art and lyrics fetching during audio download
- 64KB HTTP read/write buffers
- 256KB buffered file writer for all downloaders
- Progress updates every 64KB (reduced lock contention)
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
## [2.1.0-preview2] - 2026-01-06
### Added
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
- Change service on-the-fly without going to settings
- Available in Home, Album, and Playlist screens
- **AMOLED Dark Theme**: Pure black background for OLED screens
- Toggle in Settings > Appearance > Theme
- Saves battery on OLED/AMOLED displays
- All surface colors adjusted for true black background
- **Update Channel Setting**: Choose between Stable and Preview release channels
- Stable: Only receive stable release notifications
- Preview: Get notified about preview/beta releases too
- Configure in Settings > Options > App
### Fixed
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
- Added logging for better debugging
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- Shows "Lyrics not available" instead of loading forever
- Better error messages for timeout and not found cases
## [2.1.0-preview] - 2026-01-06
### Performance
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
- ISRC search first strategy (faster than SongLink API)
- Track ID cache with 30 minute TTL for album/playlist downloads
- Pre-warm cache when viewing album/playlist
- Parallel cover art and lyrics fetching during audio download
- 64KB HTTP read/write buffers
- 256KB buffered file writer for all downloaders
- Progress updates every 64KB (reduced lock contention)
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
### Technical
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
## [2.0.7-preview2] - 2026-01-06
### Fixed
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- iOS limitation: Empty folders cannot be selected via document picker
- Added "App Documents Folder" option as recommended default
- Shows info message explaining iOS limitation
- Files saved to app Documents folder are accessible via iOS Files app
## [2.0.7-preview] - 2026-01-05
### Changed
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
- Only includes FLAC, MP3 (LAME), and AAC codecs
- Removed x86/x86_64 architectures (emulator only)
### Technical
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- Native MethodChannel bridge for FFmpeg operations
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
## [2.0.6] - 2026-01-05
### Fixed
- **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
- Now properly converts milliseconds to seconds before display
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
- Also reads quality from existing files when skipping duplicates
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
- Verifies artist matches between Spotify metadata and streaming service
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
- Applied to Tidal, Qobuz, and Amazon downloads
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
- Now uses case-insensitive comparison when replacing existing Vorbis comments
- Fixes issue where Amazon downloads could have duplicate metadata tags
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
- Added proper PopScope handling for predictive back gesture on Android 14+
## [2.0.5] - 2026-01-05
### Added
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
### 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).
## [2.0.4] - 2026-01-04
### Fixed
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
- Shows explanation dialog before opening system settings
@@ -10,6 +553,7 @@
## [2.0.3] - 2026-01-03
### Added
- **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
- Material Expressive 3 bottom sheet UI for entering credentials
@@ -17,9 +561,11 @@
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
### Changed
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
### Fixed
- **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
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
@@ -29,6 +575,7 @@
## [2.0.2] - 2026-01-03
### Added
- **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")
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
@@ -37,13 +584,16 @@
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
### Fixed
- **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
### Removed
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
### Technical
- 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)
- Tidal API v2 response provides exact quality info
@@ -53,18 +603,21 @@
## [2.0.1] - 2026-01-03
### Added
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
- Tap to expand long track titles
- Expand icon only shows when title is truncated
- Ripple effect follows rounded corners including drag handle
### Changed
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
- All downloads now use item-based progress tracking
- Fixes duplicate notification bug when finalizing
- Cleaner codebase with single progress system
### Fixed
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
@@ -74,6 +627,7 @@
## [2.0.0] - 2026-01-03
### Added
- **Artist Search Results**: Search now shows artists alongside tracks
- Horizontal scrollable artist cards with circular avatars
- Tap artist to view their discography
@@ -94,11 +648,12 @@
- Stable users won't receive update notifications for preview versions
### Changed
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
- Header (name, cover) shows instantly from available data
- Content (albums/tracks) loads in background inside the screen
- 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
- Added "Songs" section header (matches "Artists" header style)
- Track list now in grouped card with rounded corners (like Settings)
@@ -121,6 +676,7 @@
- **Ask Before Download Default**: Now enabled by default for better UX
### Fixed
- **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
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
@@ -131,6 +687,7 @@
## [1.6.3] - 2026-01-03
### Added
- **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
- Collapsing header with cover art and gradient overlay
@@ -140,6 +697,7 @@
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
### Changed
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
- Enables native predictive back gesture animations
@@ -149,17 +707,21 @@
## [1.6.2] - 2026-01-02
### Added
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
### Changed
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
- **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
### Fixed
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
### Performance
- **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
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
@@ -171,12 +733,14 @@
## [1.6.1] - 2026-01-02
### Added
- **Background Download Service**: Downloads now continue running when app is in background
- Foreground service with wake lock prevents Android from killing downloads
- Persistent notification shows download progress
- No more "connection abort" errors when switching apps
### Fixed
- **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
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
@@ -185,11 +749,13 @@
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
### Changed
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
## [1.6.0] - 2026-01-02
### Added
- **Manual Quality Selection**: New option to choose audio quality before each download
- Toggle "Ask Before Download" in Download Settings
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
@@ -204,12 +770,14 @@
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
### Fixed
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
- Users on hotfix versions now properly receive update notifications
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
### Changed
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
- Items in same group are connected with rounded card container
- Section headers outside cards for clear visual hierarchy
@@ -218,6 +786,7 @@
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
### Improved
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
- **Dependencies Updated**:
- `share_plus`: 10.1.4 → 12.0.1
@@ -227,6 +796,7 @@
## [1.5.5] - 2026-01-02
### Added
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
- Supports track, album, playlist, and artist URLs
- Auto-fetches metadata when link is shared
@@ -253,6 +823,7 @@
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
### Changed
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
- Shows download queue at top when active
- Completed downloads auto-move to history section
@@ -263,11 +834,13 @@
- Only shows exit dialog when truly at root
### Fixed
- **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
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
### 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 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
@@ -276,26 +849,31 @@
## [1.5.0-hotfix6] - 2026-01-02
### Fixed
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
## [1.5.0-hotfix5] - 2026-01-02
### Fixed
- **App Signing**: Use key.properties as per Flutter official documentation
## [1.5.0-hotfix4] - 2026-01-02
### Fixed
- **App Signing**: Create keystore.properties in workflow for Gradle
## [1.5.0-hotfix] - 2026-01-02
### 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.
**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
- **In-App Update**: Download and install updates directly from the app
- Progress bar shows download status
- Automatic device architecture detection (arm64/arm32)
@@ -303,11 +881,13 @@ We apologize for the inconvenience. Previous releases were signed with different
- **Consistent App Signing**: All future releases will use the same signing key
### Fixed
- **Update Checker**: Now downloads APK directly instead of opening browser
## [1.5.0] - 2026-01-02
### Added
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
- Progress bar in notification during download
- Completion notification when track finishes
@@ -331,6 +911,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- Downloads correct APK for your device
### Changed
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
- **Queue UI Redesign**: Card-based layout with clearer status indicators
- Removed global pause/resume in favor of per-item controls
@@ -355,6 +936,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- "Add Music" button for quick access
### Technical
- Added `flutter_local_notifications` package for notifications
- Added notification permission request in setup screen for Android 13+
- Enabled core library desugaring for all Android subprojects
@@ -363,6 +945,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
### Performance
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
- Optimized state management: Use `select()` to only rebuild when specific state changes
@@ -371,6 +954,7 @@ We apologize for the inconvenience. Previous releases were signed with different
## [1.2.0] - 2026-01-02
### Added
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
- Material Expressive 3 design with cover art header and gradient
- Hero animation from list to detail view
@@ -383,12 +967,14 @@ We apologize for the inconvenience. Previous releases were signed with different
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
### Fixed
- **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
- **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)
### Changed
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
- Play button still available for quick playback
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
@@ -397,30 +983,34 @@ We apologize for the inconvenience. Previous releases were signed with different
## [1.1.2] - 2026-01-01
### Added
- **Update Checker**: Automatic check for new versions from GitHub releases
- Shows changelog in update dialog
- Option to disable update notifications
- **Release Changelog**: GitHub releases now include full changelog
### Changed
- Updated version to 1.1.2
## [1.1.1] - 2026-01-01
### Fixed
- **About Dialog**: Custom About dialog with cleaner layout
- **Setup Screen**: Fixed step indicator line alignment
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
- **Copyright Year**: Updated to 2026
### Changed
- Removed Theme Preview from Settings
- Added MIT License
## [1.1.0] - 2026-01-01
### Added
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
- Default: Sequential (1 at a time) for stability
- Options: 1, 2, or 3 concurrent downloads
@@ -431,15 +1021,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
### Fixed
- **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
- **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`
### Changed
- Updated version to 1.1.0
### Technical Details
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
@@ -448,6 +1041,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- Added `CleanupConnections()` export for Flutter to call via method channel
## [1.0.5] - Previous Release
- Material Expressive 3 UI
- Dynamic color support
- Swipe navigation with PageView
+20 -2
View File
@@ -1,4 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
<div align="center">
@@ -11,8 +12,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
</div>
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Screenshots
@@ -24,11 +23,30 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## Metadata Source
SpotiFLAC supports two metadata sources for searching tracks:
| Source | Pros | Cons |
|--------|------|------|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
### Using Spotify
To use Spotify as your search source without hitting rate limits:
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
2. Create an app to get your Client ID and Client Secret
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
4. Enter your Client ID and Secret
5. Change **Search Source** to Spotify
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
## Disclaimer
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
+14 -8
View File
@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
@@ -7,9 +10,9 @@ plugins {
// Load keystore properties for local builds
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = java.util.Properties()
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
@@ -32,10 +35,10 @@ android {
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
}
}
}
@@ -43,7 +46,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 34
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -94,7 +97,10 @@ repositories {
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(files("libs/gobackend.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("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
View File
@@ -6,6 +6,14 @@
-keep class io.flutter.** { *; }
-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)
-keep class gobackend.** { *; }
-keep class go.** { *; }
@@ -14,6 +22,9 @@
-keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.smartexception.** { *; }
# Apache Tika (if used by FFmpeg)
-dontwarn org.apache.tika.**
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
/**
* Foreground service to keep downloads running when app is in background.
* This prevents Android from killing the download process or throttling network.
*
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
*/
class DownloadService : Service() {
@@ -106,6 +109,19 @@ class DownloadService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
/**
* Called when the foreground service timeout is reached (Android 15+, API 35+).
* dataSync services have a 6-hour limit in a 24-hour period.
* We must call stopSelf() within a few seconds to avoid a crash.
*/
override fun onTimeout(startId: Int, fgsType: Int) {
// Log the timeout for debugging
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
// Gracefully stop the service
stopForegroundService()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
@@ -5,6 +5,8 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import gobackend.Gobackend
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -13,6 +15,7 @@ import kotlinx.coroutines.withContext
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend"
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onNewIntent(intent: Intent) {
@@ -177,6 +180,13 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.readFileMetadata(filePath)
}
result.success(response)
}
"startDownloadService" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
@@ -208,6 +218,105 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
Gobackend.preWarmTrackCacheJSON(tracksJson)
}
result.success(null)
}
"getTrackCacheSize" -> {
val size = withContext(Dispatchers.IO) {
Gobackend.getTrackCacheSize()
}
result.success(size.toInt())
}
"clearTrackCache" -> {
withContext(Dispatchers.IO) {
Gobackend.clearTrackIDCache()
}
result.success(null)
}
// Deezer API methods
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 3
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
}
result.success(response)
}
"getDeezerMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerMetadata(resourceType, resourceId)
}
result.success(response)
}
"parseDeezerUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseDeezerURLExport(url)
}
result.success(response)
}
"searchDeezerByISRC" -> {
val isrc = call.argument<String>("isrc") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerByISRC(isrc)
}
result.success(response)
}
"convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.convertSpotifyToDeezer(resourceType, spotifyId)
}
result.success(response)
}
"getSpotifyMetadataWithFallback" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
}
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()
}
} catch (e: Exception) {
@@ -215,5 +324,37 @@ class MainActivity: FlutterActivity() {
}
}
}
// FFmpeg method channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
"execute" -> {
val command = call.argument<String>("command") ?: ""
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute(command)
}
val returnCode = session.returnCode
val output = session.output ?: ""
result.success(mapOf(
"success" to ReturnCode.isSuccess(returnCode),
"returnCode" to (returnCode?.value ?: -1),
"output" to output
))
}
"getVersion" -> {
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute("-version")
}
result.success(session.output ?: "unknown")
}
else -> result.notImplemented()
}
} catch (e: Exception) {
result.error("FFMPEG_ERROR", e.message, null)
}
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+208
View File
@@ -0,0 +1,208 @@
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
class FFmpegServiceIOS {
/// Execute FFmpeg command and return result
static Future<FFmpegResultIOS> _execute(String command) async {
try {
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final output = await session.getOutput() ?? '';
return FFmpegResultIOS(
success: ReturnCode.isSuccess(returnCode),
returnCode: returnCode?.getValue() ?? -1,
output: output,
);
} catch (e) {
_log.e('FFmpeg execute error: $e');
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
}
}
/// Convert M4A (DASH segments) to FLAC
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac');
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
try {
await File(inputPath).delete();
} catch (_) {}
return outputPath;
}
_log.e('M4A to FLAC conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to MP3
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command);
if (result.success) return outputPath;
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to M4A
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}M4A';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
String command;
if (codec == 'alac') {
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
} else {
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) return outputPath;
_log.e('FLAC to M4A conversion failed: ${result.output}');
return null;
}
/// Embed cover art to FLAC file
static Future<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
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';
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 cover embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) await tempFile.delete();
} catch (_) {}
_log.e('Cover embed failed: ${result.output}');
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
static Future<bool> isAvailable() async {
try {
final session = await FFmpegKit.execute('-version');
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode);
} catch (e) {
return false;
}
}
/// Get FFmpeg version info
static Future<String?> getVersion() async {
try {
final session = await FFmpegKit.execute('-version');
return await session.getOutput();
} catch (e) {
return null;
}
}
}
class FFmpegResultIOS {
final bool success;
final int returnCode;
final String output;
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
}
+327 -80
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
@@ -10,15 +11,26 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct {
client *http.Client
regions []string // us, eu regions for DoubleDouble service
client *http.Client
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 (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
type DoubleDoubleSubmitResponse struct {
Success bool `json:"success"`
@@ -36,12 +48,114 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"`
}
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
// amazonArtistsMatch checks if the artist names are similar enough
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
}
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
apiCallResetTime: time.Now(),
}
})
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
@@ -56,7 +170,6 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
return apis
}
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
// This uses submit → poll → download mechanism
// Internal function - not exported to gomobile
@@ -64,18 +177,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
var lastError error
for _, region := range a.regions {
fmt.Printf("[Amazon] Trying region: %s...\n", region)
GoLog("[Amazon] Trying region: %s...\n", region)
// Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request
// Step 1: Submit download request with rate limiting
encodedURL := url.QueryEscape(amazonURL)
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)
if err != nil {
lastError = fmt.Errorf("failed to create request: %w", err)
@@ -85,15 +201,43 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
req.Header.Set("User-Agent", getRandomUserAgent())
fmt.Println("[Amazon] Submitting download request...")
resp, err := a.client.Do(req)
if err != nil {
lastError = fmt.Errorf("failed to submit request: %w", err)
continue
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
var resp *http.Response
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 {
resp.Body.Close()
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
if err != nil || lastError != nil {
if resp != nil {
resp.Body.Close()
}
continue
}
@@ -111,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
}
downloadID := submitResp.ID
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
GoLog("[Amazon] Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
@@ -166,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
trackName := status.Current.Name
artist := status.Current.Artist
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
return fileURL, trackName, artist, nil
} else if status.Status == "error" {
@@ -200,7 +344,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
}
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Initialize item progress (required for all downloads)
@@ -226,39 +369,70 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
// Use item progress writer
var bytesWritten int64
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
pw := NewItemProgressWriter(out, itemID)
bytesWritten, err = io.Copy(pw, resp.Body)
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
bytesWritten, err = io.Copy(out, resp.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
written, err = io.Copy(bufWriter, resp.Body)
}
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil
}
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
FilePath string
BitDepth 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
@@ -273,7 +447,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Get Amazon URL from SongLink
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 {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
@@ -295,6 +484,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Verify artist matches
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
@@ -312,11 +510,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// Download file with item ID for progress tracking
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -326,78 +542,109 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Log track info from DoubleDouble (for debugging)
if trackName != "" && artistName != "" {
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
// Read existing metadata from downloaded file BEFORE embedding
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
if metaErr == nil && existingMeta != nil {
// Use file metadata if it has valid track/disc numbers and request doesn't have them
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
}
// Embed metadata using Spotify data (more accurate than DoubleDouble)
// But preserve track/disc numbers from file if they were better
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Amazon] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Amazon] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Amazon] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
}
fmt.Println("[Amazon] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
fmt.Println("[Amazon] No lyrics available from parallel fetch")
}
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
// Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath)
if err != nil {
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
// Return 0 to indicate unknown quality
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: 0,
SampleRate: 0,
}, nil
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
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{
FilePath: outputPath,
BitDepth: quality.BitDepth,
SampleRate: quality.SampleRate,
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
}, nil
}
+742
View File
@@ -0,0 +1,742 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute
// Parallel ISRC fetching settings
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
)
// DeezerClient handles Deezer API interactions (no auth required)
type DeezerClient struct {
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string // trackID -> ISRC cache
cacheMu sync.RWMutex
}
// Singleton instance
var (
deezerClient *DeezerClient
deezerClientOnce sync.Once
)
// GetDeezerClient returns singleton Deezer client
func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string),
}
})
return deezerClient
}
// Deezer API response types
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"` // in seconds
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
}
type deezerArtist struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
}
type deezerAlbumSimple struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // 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 {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"`
Tracks struct {
Data []deezerTrack `json:"data"`
} `json:"tracks"`
}
type deezerArtistFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
NbAlbum int `json:"nb_album"`
}
type deezerPlaylistFull struct {
ID int64 `json:"id"`
Title string `json:"title"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"`
Creator struct {
Name string `json:"name"`
} `json:"creator"`
Tracks struct {
Data []deezerTrack `json:"data"`
} `json:"tracks"`
}
// SearchAll searches for tracks and artists on Deezer
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
GoLog("[Deezer] SearchAll: returning cached result\n")
return entry.data.(*SearchAllResult), nil
}
c.cacheMu.RUnlock()
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0),
Artists: make([]SearchArtistResult, 0),
}
// Search tracks - NO ISRC fetch for performance
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
var trackResp struct {
Data []deezerTrack `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err)
}
if trackResp.Error != nil {
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
}
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data {
// Convert directly without fetching ISRC - much faster
result.Tracks = append(result.Tracks, c.convertTrack(track))
}
// Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct {
Data []deezerArtist `json:"data"`
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 artistResp.Error != nil {
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
for _, artist := range artistResp.Data {
result.Artists = append(result.Artists, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
}
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
}
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Cache result
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
// GetTrack fetches a single track by Deezer ID
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
var track deezerTrack
if err := c.getJSON(ctx, trackURL, &track); err != nil {
return nil, err
}
return &TrackResponse{
Track: c.convertTrack(track),
}, nil
}
// GetAlbum fetches album with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumResponsePayload), nil
}
c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, err
}
albumImage := c.getBestAlbumImage(album)
artistName := album.Artist.Name
if len(album.Contributors) > 0 {
names := make([]string, len(album.Contributors))
for i, a := range album.Contributors {
names[i] = a.Name
}
artistName = strings.Join(names, ", ")
}
info := AlbumInfoMetadata{
TotalTracks: album.NbTracks,
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
Images: albumImage,
}
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
for _, track := range album.Tracks.Data {
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
Name: track.Title,
AlbumName: album.Title,
AlbumArtist: artistName,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: album.ReleaseDate,
TrackNumber: track.TrackPosition,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
})
}
result := &AlbumResponsePayload{
AlbumInfo: info,
TrackList: tracks,
}
c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
// GetArtist fetches artist with albums
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*ArtistResponsePayload), nil
}
c.cacheMu.RUnlock()
// Fetch artist info
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
var artist deezerArtistFull
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
return nil, err
}
artistInfo := ArtistInfoMetadata{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImageFull(artist),
Followers: artist.NbFan,
Popularity: 0,
}
// Fetch artist albums
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
var albumsResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
RecordType string `json:"record_type"` // album, single, ep, compile
} `json:"data"`
}
albums := make([]ArtistAlbumMetadata, 0)
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
for _, album := range albumsResp.Data {
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
coverURL := album.CoverXL
if coverURL == "" {
coverURL = album.CoverBig
}
if coverURL == "" {
coverURL = album.CoverMedium
}
if coverURL == "" {
coverURL = album.Cover
}
albums = append(albums, ArtistAlbumMetadata{
ID: fmt.Sprintf("deezer:%d", album.ID),
Name: album.Title,
ReleaseDate: album.ReleaseDate,
TotalTracks: album.NbTracks,
Images: coverURL,
AlbumType: albumType,
Artists: artist.Name,
})
}
}
result := &ArtistResponsePayload{
ArtistInfo: artistInfo,
Albums: albums,
}
c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
// GetPlaylist fetches playlist with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
var playlist deezerPlaylistFull
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
return nil, err
}
playlistImage := playlist.PictureXL
if playlistImage == "" {
playlistImage = playlist.PictureBig
}
if playlistImage == "" {
playlistImage = playlist.PictureMedium
}
var info PlaylistInfoMetadata
info.Tracks.Total = playlist.NbTracks
info.Owner.DisplayName = playlist.Creator.Name
info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
for _, track := range playlist.Tracks.Data {
albumImage := track.Album.CoverXL
if albumImage == "" {
albumImage = track.Album.CoverBig
}
if albumImage == "" {
albumImage = track.Album.CoverMedium
}
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
Name: track.Title,
AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: "",
TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
})
}
return &PlaylistResponsePayload{
PlaylistInfo: info,
TrackList: tracks,
}, nil
}
// SearchByISRC searches for a track by ISRC using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
// Use direct ISRC endpoint (API 2.0)
// https://api.deezer.com/2.0/track/isrc:{ISRC}
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil {
// Fallback to search if direct endpoint fails
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
var resp struct {
Data []deezerTrack `json:"data"`
}
if err := c.getJSON(ctx, searchURL, &resp); err != nil {
return nil, err
}
if len(resp.Data) == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
result := c.convertTrack(resp.Data[0])
return &result, nil
}
// Check if we got a valid response (ID > 0)
if track.ID == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
result := c.convertTrack(track)
return &result, nil
}
func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*deezerTrack, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
var track deezerTrack
if err := c.getJSON(ctx, trackURL, &track); err != nil {
return nil, err
}
return &track, nil
}
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string)
var resultMu sync.Mutex
// First, check cache for existing ISRCs
var tracksToFetch []deezerTrack
c.cacheMu.RLock()
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)
}
}
c.cacheMu.RUnlock()
if len(tracksToFetch) == 0 {
return result
}
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, deezerMaxParallelISRC)
var wg sync.WaitGroup
for _, track := range tracksToFetch {
wg.Add(1)
go func(t deezerTrack) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
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)
}
wg.Wait()
return result
}
// GetTrackISRC fetches ISRC for a single track (with caching)
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
// Check cache first
c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok {
c.cacheMu.RUnlock()
return isrc, nil
}
c.cacheMu.RUnlock()
// Fetch from API
fullTrack, err := c.fetchFullTrack(ctx, trackID)
if err != nil {
return "", err
}
// Cache the result
c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC
c.cacheMu.Unlock()
return fullTrack.ISRC, nil
}
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
if artist.PictureXL != "" {
return artist.PictureXL
}
if artist.PictureBig != "" {
return artist.PictureBig
}
if artist.PictureMedium != "" {
return artist.PictureMedium
}
return artist.Picture
}
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
if artist.PictureXL != "" {
return artist.PictureXL
}
if artist.PictureBig != "" {
return artist.PictureBig
}
if artist.PictureMedium != "" {
return artist.PictureMedium
}
return artist.Picture
}
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
if album.CoverXL != "" {
return album.CoverXL
}
if album.CoverBig != "" {
return album.CoverBig
}
if album.CoverMedium != "" {
return album.CoverMedium
}
return album.Cover
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
}
return json.Unmarshal(body, dst)
}
// parseDeezerURL is internal function, returns type and ID
func parseDeezerURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", "", fmt.Errorf("empty URL")
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", "", err
}
if parsed.Host != "www.deezer.com" && parsed.Host != "deezer.com" && parsed.Host != "deezer.page.link" {
return "", "", fmt.Errorf("not a Deezer URL")
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Skip language prefix if present (e.g., /en/, /fr/)
if len(parts) > 0 && len(parts[0]) == 2 {
parts = parts[1:]
}
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid Deezer URL format")
}
resourceType := parts[0]
resourceID := parts[1]
switch resourceType {
case "track", "album", "artist", "playlist":
return resourceType, resourceID, nil
default:
return "", "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
}
}
+222 -32
View File
@@ -1,49 +1,144 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"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)
// Uses ISRC index for fast lookup
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
if isrc == "" || outputDir == "" {
return "", false
}
// Walk through directory looking for FLAC files
var foundFile string
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
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
// Use index for fast lookup
idx := GetISRCIndex(outputDir)
return idx.lookup(isrc)
}
// 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
}
// 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)
}
}
+604 -72
View File
@@ -5,6 +5,7 @@ package gobackend
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
@@ -16,17 +17,17 @@ func ParseSpotifyURL(url string) (string, error) {
if err != nil {
return "", err
}
result := map[string]string{
"type": parsed.Type,
"id": parsed.ID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
@@ -41,18 +42,18 @@ func SetSpotifyAPICredentials(clientID, clientSecret string) {
func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := NewSpotifyMetadataClient()
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
@@ -61,18 +62,18 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := NewSpotifyMetadataClient()
results, err := client.SearchTracks(ctx, query, limit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
@@ -81,18 +82,18 @@ func SearchSpotify(query string, limit int) (string, error) {
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := NewSpotifyMetadataClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
@@ -104,12 +105,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(availability)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
@@ -132,7 +133,8 @@ type DownloadRequest struct {
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
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)
}
// DownloadResponse represents the result of a download
@@ -141,18 +143,34 @@ type DownloadResponse struct {
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"`
// Actual quality info from the source
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
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
type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
FilePath string
BitDepth 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
@@ -163,25 +181,32 @@ func DownloadTrack(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
var result DownloadResult
var err error
switch req.Service {
case "tidal":
tidalResult, tidalErr := downloadFromTidal(req)
if tidalErr == nil {
result = DownloadResult{
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
SampleRate: tidalResult.SampleRate,
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
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
@@ -189,9 +214,16 @@ func DownloadTrack(requestJSON string) (string, error) {
qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil {
result = DownloadResult{
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate,
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
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
@@ -199,33 +231,66 @@ func DownloadTrack(requestJSON string) (string, error) {
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
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
default:
return errorResponse("Unknown service: " + req.Service)
}
if err != nil {
return errorResponse(err.Error())
}
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{
Success: true,
Message: "File already exists",
FilePath: result.FilePath[7:],
AlreadyExists: true,
Service: req.Service,
Success: true,
Message: "File already exists",
FilePath: actualPath,
AlreadyExists: true,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
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)
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{
Success: true,
Message: "Download complete",
@@ -233,8 +298,15 @@ func DownloadTrack(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
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)
return string(jsonBytes), nil
}
@@ -246,21 +318,23 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
if preferredService == "" {
preferredService = "tidal"
}
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
// Create ordered list: preferred first, then others
services := []string{preferredService}
for _, s := range allServices {
@@ -268,62 +342,118 @@ func DownloadWithFallback(requestJSON string) (string, error) {
services = append(services, s)
}
}
GoLog("[DownloadWithFallback] Service order: %v\n", services)
var lastErr error
for _, service := range services {
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
req.Service = service
var result DownloadResult
var err error
switch service {
case "tidal":
tidalResult, tidalErr := downloadFromTidal(req)
if tidalErr == nil {
result = DownloadResult{
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
SampleRate: tidalResult.SampleRate,
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
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
case "qobuz":
qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil {
result = DownloadResult{
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate,
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
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
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
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
}
if err == nil {
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{
Success: true,
Message: "File already exists",
FilePath: result.FilePath[7:],
AlreadyExists: true,
Service: service,
Success: true,
Message: "File already exists",
FilePath: actualPath,
AlreadyExists: true,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
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)
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from " + service,
@@ -331,14 +461,21 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
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)
return string(jsonBytes), nil
}
lastErr = err
}
return errorResponse("All services failed. Last error: " + lastErr.Error())
}
@@ -376,6 +513,51 @@ func CleanupConnections() {
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
func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
@@ -384,27 +566,47 @@ func SetDownloadDirectory(path string) error {
// CheckDuplicate checks if a file with the given ISRC exists
func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc)
result := map[string]interface{}{
"exists": exists,
"filepath": existingFile,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
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
func BuildFilename(template string, metadataJSON string) (string, error) {
var metadata map[string]interface{}
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return "", err
}
filename := buildFilenameFromTemplate(template, metadata)
return filename, nil
}
@@ -438,7 +640,7 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
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
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// Try to extract from file first (much faster)
@@ -456,7 +658,8 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
return "", err
}
lrcContent := convertToLRC(lyricsData)
// Convert to LRC format with metadata headers (like PC version)
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
@@ -476,10 +679,339 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil
}
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
// This runs in background and returns immediately
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
SpotifyID string `json:"spotify_id"`
Service string `json:"service"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return errorResponse("Invalid JSON: " + err.Error())
}
// Convert to PreWarmCacheRequest
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
SpotifyID: t.SpotifyID,
Service: t.Service,
}
}
// Run in background
go PreWarmTrackCache(requests)
resp := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)),
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// GetTrackCacheSize returns the current track ID cache size
func GetTrackCacheSize() int {
return GetCacheSize()
}
// ClearTrackIDCache clears the track ID cache
func ClearTrackIDCache() {
ClearTrackCache()
}
// ==================== DEEZER API ====================
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
// Returns JSON with tracks and artists arrays
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetDeezerMetadata fetches metadata from Deezer URL or ID
// resourceType: track, album, artist, playlist
// resourceID: Deezer ID
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := GetDeezerClient()
var data interface{}
var err error
switch resourceType {
case "track":
data, err = client.GetTrack(ctx, resourceID)
case "album":
data, err = client.GetAlbum(ctx, resourceID)
case "artist":
data, err = client.GetArtist(ctx, resourceID)
case "playlist":
data, err = client.GetPlaylist(ctx, resourceID)
default:
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
}
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
func ParseDeezerURLExport(url string) (string, error) {
resourceType, resourceID, err := parseDeezerURL(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": resourceType,
"id": resourceID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := GetDeezerClient()
track, err := client.SearchByISRC(ctx, isrc)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(track)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
// Useful when Spotify API is rate limited
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
songlink := NewSongLinkClient()
deezerClient := GetDeezerClient()
// For tracks, we can use SongLink to get Deezer ID
if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
// Fetch metadata from Deezer
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
}
jsonBytes, err := json.Marshal(trackResp)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// For albums, SongLink also provides mapping
if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
// Fetch album metadata from Deezer
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
}
jsonBytes, err := json.Marshal(albumResp)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// 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)
}
// GetSpotifyMetadataWithDeezerFallback tries Spotify first, falls back to Deezer on rate limit
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Try Spotify first
client := NewSpotifyMetadataClient()
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err == nil {
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// Check if it's a rate limit error
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
// Not a rate limit error, return original error
return "", err
}
// Rate limited - try Deezer fallback for tracks and albums
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
}
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" {
// Convert to Deezer
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
// Artist and playlist not supported for fallback
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. 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) {
// 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, "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{
Success: false,
Error: msg,
Success: false,
Error: msg,
ErrorType: errorType,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
+271 -16
View File
@@ -1,36 +1,75 @@
package gobackend
import (
"crypto/tls"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"time"
)
// HTTP utility functions for consistent request handling across all downloaders
// User-Agent pool for Android Chrome browsers
var userAgentTemplates = []string{
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
func getRandomUserAgent() string {
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
// Some APIs may block mobile User-Agents, so we use desktop format
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
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)
func getRandomUserAgent() string {
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
func getRandomMacUserAgent() string {
macMajor := rand.Intn(4) + 11 // macOS 11-14
macMinor := rand.Intn(5) + 4 // Minor 4-8
webkitMajor := rand.Intn(7) + 530
webkitMinor := rand.Intn(7) + 30
chromeMajor := rand.Intn(25) + 80
chromeBuild := rand.Intn(1500) + 3000
chromePatch := rand.Intn(65) + 60
safariMajor := rand.Intn(7) + 530
safariMinor := rand.Intn(6) + 30
androidVersion := rand.Intn(5) + 10 // Android 10-14
deviceModel := rand.Intn(900) + 100 // Random model number
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(5000) + 5000
chromePatch := rand.Intn(200) + 100
return fmt.Sprintf(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
macMajor,
macMinor,
webkitMajor,
webkitMinor,
chromeMajor,
chromeBuild,
chromePatch,
safariMajor,
safariMinor,
)
}
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
@@ -43,6 +82,7 @@ const (
)
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -56,6 +96,9 @@ var sharedTransport = &http.Transport{
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
}
// Shared HTTP client for general requests (reuses connections)
@@ -96,9 +139,15 @@ func CloseIdleConnections() {
}
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
return client.Do(req)
resp, err := client.Do(req)
if err != nil {
// Check for ISP blocking
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
}
// RetryConfig holds configuration for retry logic
@@ -121,9 +170,11 @@ func DefaultRetryConfig() RetryConfig {
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
// Handles 429 (Too Many Requests) responses with Retry-After header
// Also detects and logs ISP blocking
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error
delay := config.InitialDelay
requestURL := req.URL.String()
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Clone request for retry (body needs to be re-readable)
@@ -133,7 +184,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
resp, err := client.Do(reqCopy)
if err != nil {
lastErr = err
// Check for ISP blocking on network errors
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
// Don't retry if ISP blocking is detected - it won't help
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
}
if attempt < config.MaxRetries {
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
attempt+1, config.MaxRetries+1, err, delay)
time.Sleep(delay)
delay = calculateNextDelay(delay, config)
}
@@ -154,17 +214,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
}
lastErr = fmt.Errorf("rate limited (429)")
if attempt < config.MaxRetries {
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
time.Sleep(delay)
delay = calculateNextDelay(delay, config)
}
continue
}
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
}
for _, indicator := range ispBlockingIndicators {
if strings.Contains(bodyStr, indicator) {
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
LogError("HTTP", "Domain: %s", req.URL.Host)
LogError("HTTP", "Response contains: %s", indicator)
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
}
}
}
// Server errors (5xx) - retry
if resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
if attempt < config.MaxRetries {
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
time.Sleep(delay)
delay = calculateNextDelay(delay, config)
}
@@ -258,3 +344,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
}
return msg
}
// ISPBlockingError represents an error caused by ISP blocking
type ISPBlockingError struct {
Domain string
Reason string
OriginalErr error
}
func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
// IsISPBlocking checks if an error is likely caused by ISP blocking
// Returns the ISPBlockingError if detected, nil otherwise
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
}
// Extract domain from URL
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
// Check for DNS resolution failure (common ISP blocking method)
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
return &ISPBlockingError{
Domain: domain,
Reason: "DNS resolution failed - domain may be blocked by ISP",
OriginalErr: err,
}
}
}
// Check for connection refused (ISP firewall blocking)
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
// Check for specific syscall errors
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
case syscall.ECONNREFUSED:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection refused - port may be blocked by ISP/firewall",
OriginalErr: err,
}
case syscall.ECONNRESET:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection reset - ISP may be intercepting traffic",
OriginalErr: err,
}
case syscall.ETIMEDOUT:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection timed out - ISP may be blocking access",
OriginalErr: err,
}
case syscall.ENETUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Network unreachable - ISP may be blocking route",
OriginalErr: err,
}
case syscall.EHOSTUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Host unreachable - ISP may be blocking destination",
OriginalErr: err,
}
}
}
}
}
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
Domain: domain,
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
OriginalErr: err,
}
}
// Check error message patterns for common ISP blocking indicators
blockingPatterns := []struct {
pattern string
reason string
}{
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
{"connection refused", "Connection refused - port may be blocked"},
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
{"certificate", "Certificate error - ISP may be using MITM proxy"},
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
}
for _, bp := range blockingPatterns {
if strings.Contains(errStr, bp.pattern) {
return &ISPBlockingError{
Domain: domain,
Reason: bp.reason,
OriginalErr: err,
}
}
}
return nil
}
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
LogError(tag, "Domain: %s", ispErr.Domain)
LogError(tag, "Reason: %s", ispErr.Reason)
LogError(tag, "Original error: %v", ispErr.OriginalErr)
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
return true
}
return false
}
// extractDomain extracts the domain from a URL string
func extractDomain(rawURL string) string {
if rawURL == "" {
return "unknown"
}
parsed, err := url.Parse(rawURL)
if err != nil {
// Try to extract domain manually
rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 {
return rawURL[:idx]
}
return rawURL
}
if parsed.Host != "" {
return parsed.Host
}
return "unknown"
}
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
}
if CheckAndLogISPBlocking(err, requestURL, tag) {
domain := extractDomain(requestURL)
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
}
return err
}
+203
View File
@@ -0,0 +1,203 @@
package gobackend
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// LogEntry represents a single log entry
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Tag string `json:"tag"`
Message string `json:"message"`
}
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
type LogBuffer struct {
entries []LogEntry
maxSize int
mu sync.RWMutex
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
}
var (
globalLogBuffer *LogBuffer
logBufferOnce sync.Once
)
// GetLogBuffer returns the singleton log buffer instance
func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, 500),
maxSize: 500,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
}
})
return globalLogBuffer
}
// SetLoggingEnabled enables or disables logging
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.loggingEnabled = enabled
}
// IsLoggingEnabled returns whether logging is enabled
func (lb *LogBuffer) IsLoggingEnabled() bool {
lb.mu.RLock()
defer lb.mu.RUnlock()
return lb.loggingEnabled
}
// Add adds a log entry to the buffer
func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock()
defer lb.mu.Unlock()
// Skip if logging is disabled (except for errors which are always logged)
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
return
}
entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"),
Level: level,
Tag: tag,
Message: message,
}
if len(lb.entries) >= lb.maxSize {
// Remove oldest entry
lb.entries = lb.entries[1:]
}
lb.entries = append(lb.entries, entry)
// Also print to logcat for debugging
fmt.Printf("[%s] %s\n", tag, message)
}
// GetAll returns all log entries as JSON
func (lb *LogBuffer) GetAll() string {
lb.mu.RLock()
defer lb.mu.RUnlock()
jsonBytes, _ := json.Marshal(lb.entries)
return string(jsonBytes)
}
// getSince returns log entries since the given index (internal use)
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
lb.mu.RLock()
defer lb.mu.RUnlock()
if index < 0 {
index = 0
}
if index >= len(lb.entries) {
return []LogEntry{}, len(lb.entries)
}
entries := lb.entries[index:]
return entries, len(lb.entries)
}
// Clear clears all log entries
func (lb *LogBuffer) Clear() {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.entries = lb.entries[:0]
}
// Count returns the number of log entries
func (lb *LogBuffer) Count() int {
lb.mu.RLock()
defer lb.mu.RUnlock()
return len(lb.entries)
}
// Helper functions for logging with different levels
func LogDebug(tag, format string, args ...interface{}) {
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
}
func LogInfo(tag, format string, args ...interface{}) {
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
}
func LogWarn(tag, format string, args ...interface{}) {
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
}
func LogError(tag, format string, args ...interface{}) {
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
}
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
// It parses the tag from the format string if it starts with [Tag]
func GoLog(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
message = strings.TrimSuffix(message, "\n")
// Extract tag from message if present (e.g., "[Tidal] message")
tag := "Go"
level := "INFO"
if strings.HasPrefix(message, "[") {
endBracket := strings.Index(message, "]")
if endBracket > 1 {
tag = message[1:endBracket]
message = strings.TrimSpace(message[endBracket+1:])
}
}
// Determine level from message content
msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
level = "ERROR"
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
level = "WARN"
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
level = "INFO"
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
level = "DEBUG"
}
GetLogBuffer().Add(level, tag, message)
}
// Exported functions for Flutter
// GetLogs returns all logs as JSON array
func GetLogs() string {
return GetLogBuffer().GetAll()
}
// GetLogsSince returns logs since the given index
// Returns JSON: {"logs": [...], "next_index": N}
func GetLogsSince(index int) string {
entries, nextIndex := GetLogBuffer().getSince(index)
logsJson, _ := json.Marshal(entries)
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
return result
}
// ClearLogs clears all logs
func ClearLogs() {
GetLogBuffer().Clear()
}
// GetLogCount returns the number of log entries
func GetLogCount() int {
return GetLogBuffer().Count()
}
// SetLoggingEnabled enables or disables logging from Flutter
func SetLoggingEnabled(enabled bool) {
GetLogBuffer().SetLoggingEnabled(enabled)
}
+41
View File
@@ -248,6 +248,8 @@ func msToLRCTimestamp(ms int64) string {
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 {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -272,6 +274,45 @@ func convertToLRC(lyrics *LyricsResponse) 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 {
patterns := []string{
`\s*\(feat\..*?\)`,
+445 -49
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
@@ -57,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 {
if metadata.TotalTracks > 0 {
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
@@ -65,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
}
}
if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
setComment(cmt, "ISRC", metadata.ISRC)
}
if metadata.Description != "" {
setComment(cmt, "DESCRIPTION", metadata.Description)
}
@@ -104,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front Cover",
@@ -161,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 {
if metadata.TotalTracks > 0 {
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
@@ -169,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
}
}
if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
setComment(cmt, "ISRC", metadata.ISRC)
}
if metadata.Description != "" {
setComment(cmt, "DESCRIPTION", metadata.Description)
}
@@ -203,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front Cover",
@@ -256,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
}
discNum := getComment(cmt, "DISCNUMBER")
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
// Also try DISC variant
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
}
// Try DATE variants
if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR")
}
break
}
@@ -273,10 +293,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
return
}
// Remove existing
// Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- {
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
comment := cmt.Comments[i]
eqIdx := strings.Index(comment, "=")
if eqIdx > 0 {
existingKey := strings.ToUpper(comment[:eqIdx])
if existingKey == keyUpper {
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
}
}
}
// Add new
@@ -284,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
}
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments {
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" {
return comment[len(key)+1:]
if len(comment) > len(key) {
// Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
}
}
}
return ""
@@ -349,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) {
if err != nil {
continue
}
// Try LYRICS tag first
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
// Fallback to UNSYNCEDLYRICS
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
@@ -369,12 +400,14 @@ func ExtractLyrics(filePath string) (string, error) {
// AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
}
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
// 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) {
file, err := os.Open(filePath)
if err != nil {
@@ -382,45 +415,408 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}
defer file.Close()
// Read FLAC marker (4 bytes: "fLaC")
// Read first 4 bytes to detect file type
marker := make([]byte, 4)
if _, err := file.Read(marker); err != nil {
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)
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
// Bytes 1-3: block length (24-bit big-endian)
header := make([]byte, 4)
if _, err := file.Read(header); err != nil {
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
// First 4 bytes are size, next 4 should be "ftyp"
file.Seek(0, 0) // Reset to beginning
header8 := make([]byte, 8)
if _, err := file.Read(header8); 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")
if string(header8[4:8]) == "ftyp" {
// It's an M4A/MP4 file, use M4A quality reader
file.Close() // Close before calling GetM4AQuality which opens the file again
return GetM4AQuality(filePath)
}
// Read STREAMINFO block (34 bytes minimum)
// 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)
}
// Parse sample rate (20 bits starting at byte 10)
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
// Parse bits per sample (5 bits)
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
return AudioQuality{
BitDepth: bitsPerSample,
SampleRate: sampleRate,
}, nil
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
}
// ========================================
// M4A (MP4/AAC) Metadata Embedding
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
// This is a simplified implementation that writes metadata to the file
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
// Read the entire file
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read M4A file: %w", err)
}
// Find moov atom position
moovPos := findAtom(data, "moov", 0)
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")
}
+289
View File
@@ -0,0 +1,289 @@
package gobackend
import (
"fmt"
"sync"
"time"
)
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
AmazonTrackID string
ExpiresAt time.Time
}
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
}
var (
globalTrackIDCache *TrackIDCache
trackIDCacheOnce sync.Once
)
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes
}
})
return globalTrackIDCache
}
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.cache[isrc]
if !exists || time.Now().After(entry.ExpiresAt) {
return nil
}
return entry
}
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.TidalTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.QobuzTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.AmazonTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// Clear removes all cached entries
func (c *TrackIDCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry)
}
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct {
CoverData []byte
LyricsData *LyricsResponse
LyricsLRC string
CoverErr error
LyricsErr error
}
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
spotifyID string,
trackName string,
artistName string,
embedLyrics bool,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
if err != nil {
result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else {
result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
}
}()
}
// Fetch lyrics in parallel
if embedLyrics {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = 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))
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
}
}()
}
wg.Wait()
return result
}
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct {
ISRC string
TrackName string
ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup)
Service string // "tidal", "qobuz", "amazon"
}
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
// This runs in background while user is viewing the track list
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 {
return
}
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
// Limit concurrent pre-warm requests
semaphore := make(chan struct{}, 3) // Max 3 concurrent
var wg sync.WaitGroup
for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
wg.Add(1)
go func(r PreWarmCacheRequest) {
defer wg.Done()
semaphore <- struct{}{} // Acquire
defer func() { <-semaphore }() // Release
switch r.Service {
case "tidal":
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz":
preWarmQobuzCache(r.ISRC)
case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID)
}
}(req)
}
wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
}
func preWarmTidalCache(isrc, trackName, artistName string) {
downloader := NewTidalDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
}
}
func preWarmQobuzCache(isrc string) {
downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
}
}
func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
}
// ========================================
// Exported Functions for Flutter
// ========================================
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
return nil
}
// ClearTrackCache clears the track ID cache
func ClearTrackCache() {
GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
}
// GetCacheSize returns the current cache size
func GetCacheSize() int {
return GetTrackIDCache().Size()
}
+52 -8
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"sync"
"time"
)
// DownloadProgress represents current download progress
@@ -23,6 +24,7 @@ type ItemProgress struct {
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
}
@@ -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
func CompleteItemProgress(itemID string) {
multiMu.Lock()
@@ -196,27 +212,55 @@ func getDownloadDir() string {
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
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
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
now := time.Now()
return &ItemProgressWriter{
writer: w,
itemID: itemID,
current: 0,
writer: w,
itemID: itemID,
current: 0,
lastReported: 0,
startTime: now,
lastTime: now,
lastBytes: 0,
}
}
// Write implements io.Writer
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
if err != nil {
return n, err
}
pw.current += int64(n)
SetItemBytesReceived(pw.itemID, pw.current)
// Update progress when we've received at least 64KB since last update
// Also update on first write to show download has started
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
// 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.lastTime = now
pw.lastBytes = pw.current
}
return n, nil
}
+638 -80
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
@@ -9,6 +10,8 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"sync"
)
// QobuzDownloader handles Qobuz downloads
@@ -18,6 +21,12 @@ type QobuzDownloader struct {
apiURL string
}
var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
)
// QobuzTrack represents a Qobuz track
type QobuzTrack struct {
ID int64 `json:"id"`
@@ -39,12 +48,257 @@ type QobuzTrack struct {
} `json:"performer"`
}
// NewQobuzDownloader creates a new Qobuz downloader
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
appID: "798273057",
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedArtist)
foundLatin := qobuzIsLatinScript(foundArtist)
if expectedLatin != foundLatin {
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
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
func qobuzIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// containsQueryQobuz checks if a query already exists in the list
func containsQueryQobuz(queries []string, query string) bool {
for _, q := range queries {
if q == query {
return true
}
}
return false
}
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
appID: "798273057",
}
})
return globalQobuzDownloader
}
// GetAvailableAPIs returns list of available Qobuz APIs
@@ -53,8 +307,8 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
}
var apis []string
@@ -112,11 +366,107 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
// Find ISRC matches
var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
}
}
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 10 seconds tolerance
if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration)
}
// No duration to verify, return first match
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
if len(result.Tracks.Items) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
}
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0)
}
// SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
// SearchTrackByMetadataWithDuration searches for a track with duration verification
// Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies
// Try multiple search strategies (same as Tidal/PC version)
queries := []string{}
// Strategy 1: Artist + Track name
@@ -129,8 +479,54 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
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
searchedQueries := make(map[string]bool)
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)
if err != nil {
@@ -139,6 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
continue
}
@@ -159,19 +556,82 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
resp.Body.Close()
if len(result.Tracks.Items) > 0 {
// Return first result with best quality
for i := range result.Tracks.Items {
track := &result.Tracks.Items[i]
if track.MaximumBitDepth >= 24 {
return track, nil
}
}
// Return first result if no hi-res found
return &result.Tracks.Items[0], nil
GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
allTracks = append(allTracks, result.Tracks.Items...)
}
}
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
if len(allTracks) == 0 {
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
}
// Filter by title match first (NEW - like Tidal)
var titleMatches []*QobuzTrack
for i := range allTracks {
track := &allTracks[i]
if qobuzTitlesMatch(trackName, track.Title) {
titleMatches = append(titleMatches, track)
}
}
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
// If no title matches, log warning but continue with all tracks
tracksToCheck := titleMatches
if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
for i := range allTracks {
tracksToCheck = append(tracksToCheck, &allTracks[i])
}
}
// If duration verification is requested
if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack
for _, track := range tracksToCheck {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= 10 {
durationMatches = append(durationMatches, track)
}
}
if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
track.Title, track.Performer.Name)
return track, nil
}
}
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
durationMatches[0].Title, durationMatches[0].Performer.Name)
return durationMatches[0], nil
}
// No duration match found
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
}
// No duration verification, return best quality from title matches
for _, track := range tracksToCheck {
if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
track.Title, track.Performer.Name)
return track, nil
}
}
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
@@ -190,7 +650,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
// The apiURL already includes the path, just append trackID and 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)
if err != nil {
@@ -235,7 +695,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
}
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
}
@@ -283,33 +743,69 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
// Use item progress writer
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
written, err = io.Copy(bufWriter, resp.Body)
}
return err
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
return nil
}
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
FilePath string
BitDepth 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
@@ -321,27 +817,68 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack
var err error
// Strategy 1: Search by ISRC
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
track, err = downloader.SearchTrackByISRC(req.ISRC)
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil {
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
track = nil
}
}
}
// Strategy 2: Search by metadata
// Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist AND title
if track != nil {
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title)
track = nil
}
}
}
// Strategy 2: Search by metadata with duration verification (includes title verification)
if track == nil {
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
}
if track == nil {
errMsg := "could not find track on Qobuz"
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
if err != nil {
errMsg = err.Error()
}
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
}
// Log match found and cache the track ID
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
@@ -371,12 +908,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz
}
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get actual quality from track metadata
actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
@@ -384,11 +921,29 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Download file with item ID for progress tracking
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -396,59 +951,62 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
SetItemFinalizing(req.ItemID)
}
// Embed metadata
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
// 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
}
// Download cover to memory (avoids file permission issues on Android)
metadata := Metadata{
Title: track.Title,
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC,
}
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Qobuz] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Qobuz] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Qobuz] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Qobuz] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Qobuz] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
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{
FilePath: outputPath,
BitDepth: actualBitDepth,
SampleRate: actualSampleRate,
FilePath: outputPath,
BitDepth: actualBitDepth,
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
}
+222
View File
@@ -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)
}
+397 -6
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -20,20 +22,37 @@ type TrackAvailability struct {
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
}
// NewSongLinkClient creates a new SongLink client
var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
)
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
}
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
}
})
return globalSongLinkClient
}
// CheckTrackAvailability checks track availability on streaming platforms
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
songLinkRateLimiter.WaitForSlot()
@@ -57,8 +76,18 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
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 {
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)
@@ -92,7 +121,15 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.AmazonURL = amazonLink.URL
}
// Check Qobuz using ISRC
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
@@ -151,3 +188,357 @@ func checkQobuzAvailability(isrc string) bool {
return searchResp.Tracks.Total > 0
}
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string {
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
parts := strings.Split(deezerURL, "/")
if len(parts) > 0 {
// Get the last part which should be the ID
lastPart := parts[len(parts)-1]
// Remove any query parameters
if idx := strings.Index(lastPart, "?"); idx > 0 {
lastPart = lastPart[:idx]
}
return lastPart
}
return ""
}
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
return "", err
}
if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("track not found on Deezer")
}
return availability.DeezerID, nil
}
// AlbumAvailability represents album availability on different platforms
type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"`
Deezer bool `json:"deezer"`
DeezerURL string `json:"deezer_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
}
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build API URL for album
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
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 album availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("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 := &AlbumAvailability{
SpotifyID: spotifyAlbumID,
}
// 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
}
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
if err != nil {
return "", err
}
if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("album not found on Deezer")
}
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
}
+160 -19
View File
@@ -495,6 +495,17 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
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 {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
@@ -502,15 +513,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
Images []image `json:"images"`
Artists []artist `json:"artists"`
Tracks struct {
Items []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"`
} `json:"items"`
Items []trackItem `json:"items"`
Next string `json:"next"`
} `json:"tracks"`
}
@@ -527,10 +531,38 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
Images: albumImage,
}
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
for _, item := range data.Tracks.Items {
// Fetch ISRC for each track
isrc := c.fetchTrackISRC(ctx, item.ID, token)
// Collect all tracks (including paginated)
allTrackItems := data.Tracks.Items
nextURL := data.Tracks.Next
// Fetch remaining tracks using pagination (no limit)
for nextURL != "" {
var pageData struct {
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{
SpotifyID: item.ID,
@@ -566,7 +598,49 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
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) {
// First request to get playlist info and first batch of tracks
var data struct {
Name string `json:"name"`
Images []image `json:"images"`
@@ -577,7 +651,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
Items []struct {
Track *trackFull `json:"track"`
} `json:"items"`
Total int `json:"total"`
Total int `json:"total"`
Next string `json:"next"`
} `json:"tracks"`
}
@@ -591,7 +666,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images)
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items {
if item.Track == nil {
continue
@@ -615,6 +693,51 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
})
}
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
nextURL := data.Tracks.Next
for nextURL != "" {
var pageData struct {
Items []struct {
Track *trackFull `json:"track"`
} `json:"items"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break
}
for _, item := range pageData.Items {
if item.Track == nil {
continue
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.Track.ID,
Artists: joinArtists(item.Track.Artists),
Name: item.Track.Name,
AlbumName: item.Track.Album.Name,
AlbumArtist: joinArtists(item.Track.Album.Artists),
DurationMS: item.Track.DurationMS,
Images: firstImageURL(item.Track.Album.Images),
ReleaseDate: item.Track.Album.ReleaseDate,
TrackNumber: item.Track.TrackNumber,
TotalTracks: item.Track.Album.TotalTracks,
DiscNumber: item.Track.DiscNumber,
ExternalURL: item.Track.ExternalURL.Spotify,
ISRC: item.Track.ExternalID.ISRC,
AlbumID: item.Track.Album.ID,
AlbumURL: item.Track.Album.ExternalURL.Spotify,
})
}
nextURL = pageData.Next
}
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
return &PlaylistResponsePayload{
PlaylistInfo: info,
TrackList: tracks,
@@ -781,8 +904,16 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
return err
}
// Set headers (same as PC version baseHeaders)
req.Header.Set("User-Agent", c.userAgent)
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 != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
@@ -809,13 +940,23 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
c.rngMu.Lock()
defer c.rngMu.Unlock()
chromeMajor := 80 + c.rng.Intn(25)
chromeBuild := 3000 + c.rng.Intn(1500)
chromePatch := 60 + c.rng.Intn(65)
// Use Mac User-Agent format (same as PC version)
macMajor := c.rng.Intn(4) + 11 // 11-14
macMinor := c.rng.Intn(5) + 4 // 4-8
webkitMajor := c.rng.Intn(7) + 530 // 530-536
webkitMinor := c.rng.Intn(7) + 30 // 30-36
chromeMajor := c.rng.Intn(25) + 80 // 80-104
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(
"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,
safariMajor, safariMinor,
)
}
+984 -144
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+2 -2
View File
@@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
+100
View File
@@ -181,6 +181,106 @@ import Gobackend // Import Go framework
GobackendCleanupConnections()
return nil
case "readFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendReadFileMetadata(filePath, &error)
if let error = error { throw error }
return response
case "searchDeezerAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
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:
throw NSError(
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" : "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"
}
}
{"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"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+4 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '2.0.4';
static const String buildNumber = '34';
static const String version = '2.2.8';
static const String buildNumber = '50';
static const String fullVersion = '$version+$buildNumber';
@@ -15,4 +15,6 @@ class AppInfo {
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
}
+32
View File
@@ -13,6 +13,14 @@ enum DownloadStatus {
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
}
@JsonSerializable()
class DownloadItem {
final String id;
@@ -20,8 +28,10 @@ class DownloadItem {
final String service;
final DownloadStatus status;
final double progress;
final double speedMBps; // Download speed in MB/s
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
@@ -31,8 +41,10 @@ class DownloadItem {
required this.service,
this.status = DownloadStatus.queued,
this.progress = 0.0,
this.speedMBps = 0.0,
this.filePath,
this.error,
this.errorType,
required this.createdAt,
this.qualityOverride,
});
@@ -43,8 +55,10 @@ class DownloadItem {
String? service,
DownloadStatus? status,
double? progress,
double? speedMBps,
String? filePath,
String? error,
DownloadErrorType? errorType,
DateTime? createdAt,
String? qualityOverride,
}) {
@@ -54,13 +68,31 @@ class DownloadItem {
service: service ?? this.service,
status: status ?? this.status,
progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
errorType: errorType ?? this.errorType,
createdAt: createdAt ?? this.createdAt,
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';
default:
return error ?? 'An error occurred';
}
}
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
_$DownloadItemFromJson(json);
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
+11
View File
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
);
@@ -27,8 +29,10 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'service': instance.service,
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
'speedMBps': instance.speedMBps,
'filePath': instance.filePath,
'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
};
@@ -41,3 +45,10 @@ const _$DownloadStatusEnumMap = {
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
};
const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.unknown: 'unknown',
DownloadErrorType.notFound: 'notFound',
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
};
+16
View File
@@ -14,13 +14,17 @@ class AppSettings {
final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3
final bool checkForUpdates; // Check for updates on app start
final String updateChannel; // stable, preview
final bool hasSearchedBefore; // Hide helper text after first search
final String folderOrganization; // none, artist, album, artist_album
final String historyViewMode; // list, grid
final String historyFilterMode; // all, albums, singles
final bool askQualityBeforeDownload; // Show quality picker before each download
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
final String metadataSource; // spotify, deezer - source for search and metadata
final bool enableLogging; // Enable detailed logging for debugging
const AppSettings({
this.defaultService = 'tidal',
@@ -33,13 +37,17 @@ class AppSettings {
this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off)
this.checkForUpdates = true, // Default: enabled
this.updateChannel = 'stable', // Default: stable releases only
this.hasSearchedBefore = false, // Default: show helper text
this.folderOrganization = 'none', // Default: no folder organization
this.historyViewMode = 'grid', // Default: grid view
this.historyFilterMode = 'all', // Default: show all
this.askQualityBeforeDownload = true, // Default: ask quality before download
this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials
this.useCustomSpotifyCredentials = true, // Default: use custom if set
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
this.enableLogging = false, // Default: disabled for performance
});
AppSettings copyWith({
@@ -53,13 +61,17 @@ class AppSettings {
bool? isFirstLaunch,
int? concurrentDownloads,
bool? checkForUpdates,
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
String? spotifyClientId,
String? spotifyClientSecret,
bool? useCustomSpotifyCredentials,
String? metadataSource,
bool? enableLogging,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -72,13 +84,17 @@ class AppSettings {
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
);
}
+8
View File
@@ -17,14 +17,18 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -39,11 +43,15 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
'metadataSource': instance.metadataSource,
'enableLogging': instance.enableLogging,
};
+10 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
const String kThemeModeKey = 'theme_mode';
const String kUseDynamicColorKey = 'use_dynamic_color';
const String kSeedColorKey = 'seed_color';
const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954;
@@ -13,11 +14,13 @@ class ThemeSettings {
final ThemeMode themeMode;
final bool useDynamicColor;
final int seedColorValue;
final bool useAmoled; // Pure black background for OLED screens
const ThemeSettings({
this.themeMode = ThemeMode.system,
this.useDynamicColor = true,
this.seedColorValue = kDefaultSeedColor,
this.useAmoled = false,
});
/// Get seed color as Color object
@@ -28,11 +31,13 @@ class ThemeSettings {
ThemeMode? themeMode,
bool? useDynamicColor,
int? seedColorValue,
bool? useAmoled,
}) {
return ThemeSettings(
themeMode: themeMode ?? this.themeMode,
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
seedColorValue: seedColorValue ?? this.seedColorValue,
useAmoled: useAmoled ?? this.useAmoled,
);
}
@@ -41,6 +46,7 @@ class ThemeSettings {
kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor,
kSeedColorKey: seedColorValue,
kUseAmoledKey: useAmoled,
};
/// Create from JSON map
@@ -49,6 +55,7 @@ class ThemeSettings {
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
useAmoled: json[kUseAmoledKey] as bool? ?? false,
);
}
@@ -58,12 +65,13 @@ class ThemeSettings {
return other is ThemeSettings &&
other.themeMode == themeMode &&
other.useDynamicColor == useDynamicColor &&
other.seedColorValue == seedColorValue;
other.seedColorValue == seedColorValue &&
other.useAmoled == useAmoled;
}
@override
int get hashCode =>
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
}
/// Helper to convert string to ThemeMode
+8
View File
@@ -16,6 +16,7 @@ class Track {
final int? trackNumber;
final int? discNumber;
final String? releaseDate;
final String? deezerId;
final ServiceAvailability? availability;
const Track({
@@ -30,6 +31,7 @@ class Track {
this.trackNumber,
this.discNumber,
this.releaseDate,
this.deezerId,
this.availability,
});
@@ -42,17 +44,23 @@ class ServiceAvailability {
final bool tidal;
final bool qobuz;
final bool amazon;
final bool deezer;
final String? tidalUrl;
final String? qobuzUrl;
final String? amazonUrl;
final String? deezerUrl;
final String? deezerId;
const ServiceAvailability({
this.tidal = false,
this.qobuz = false,
this.amazon = false,
this.deezer = false,
this.tidalUrl,
this.qobuzUrl,
this.amazonUrl,
this.deezerUrl,
this.deezerId,
});
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
+8
View File
@@ -18,6 +18,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
deezerId: json['deezerId'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
@@ -37,6 +38,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'deezerId': instance.deezerId,
'availability': instance.availability,
};
@@ -45,9 +47,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
tidal: json['tidal'] as bool? ?? false,
qobuz: json['qobuz'] as bool? ?? false,
amazon: json['amazon'] as bool? ?? false,
deezer: json['deezer'] as bool? ?? false,
tidalUrl: json['tidalUrl'] as String?,
qobuzUrl: json['qobuzUrl'] as String?,
amazonUrl: json['amazonUrl'] as String?,
deezerUrl: json['deezerUrl'] as String?,
deezerId: json['deezerId'] as String?,
);
Map<String, dynamic> _$ServiceAvailabilityToJson(
@@ -56,7 +61,10 @@ Map<String, dynamic> _$ServiceAvailabilityToJson(
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'deezer': instance.deezer,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
'deezerUrl': instance.deezerUrl,
'deezerId': instance.deezerId,
};
File diff suppressed because it is too large Load Diff
+52
View File
@@ -3,8 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
class SettingsNotifier extends Notifier<AppSettings> {
@override
@@ -18,8 +21,35 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
// Run migrations if needed
await _runMigrations(prefs);
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
// Sync logging state
LogBuffer.loggingEnabled = state.enableLogging;
}
}
/// Run one-time migrations for settings
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
// Migration 1: Set metadataSource to 'deezer' for existing users
// Only apply if user hasn't enabled custom Spotify credentials
// (users with custom credentials likely prefer Spotify)
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
// Save current migration version
if (lastMigration < _currentMigrationVersion) {
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
}
}
@@ -96,6 +126,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUpdateChannel(String channel) {
state = state.copyWith(updateChannel: channel);
_saveSettings();
}
void setHasSearchedBefore() {
if (!state.hasSearchedBefore) {
state = state.copyWith(hasSearchedBefore: true);
@@ -113,6 +148,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setHistoryFilterMode(String mode) {
state = state.copyWith(historyFilterMode: mode);
_saveSettings();
}
void setAskQualityBeforeDownload(bool enabled) {
state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings();
@@ -151,6 +191,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
_applySpotifyCredentials();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
_saveSettings();
}
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
// Sync logging state to LogBuffer
LogBuffer.loggingEnabled = enabled;
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+9
View File
@@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey);
final useAmoled = prefs.getBool(kUseAmoledKey);
state = ThemeSettings(
themeMode: _themeModeFromString(modeString),
useDynamicColor: useDynamic ?? true,
seedColorValue: seedColor ?? kDefaultSeedColor,
useAmoled: useAmoled ?? false,
);
} catch (e) {
debugPrint('Error loading theme settings: $e');
@@ -43,6 +45,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue);
await prefs.setBool(kUseAmoledKey, state.useAmoled);
} catch (e) {
debugPrint('Error saving theme settings: $e');
}
@@ -72,6 +75,12 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage();
}
/// Enable or disable AMOLED mode (pure black background)
Future<void> setUseAmoled(bool value) async {
state = state.copyWith(useAmoled: value);
await _saveToStorage();
}
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
+122 -20
View File
@@ -1,6 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('TrackProvider');
class TrackState {
final List<Track> tracks;
@@ -114,7 +117,7 @@ class TrackNotifier extends Notifier<TrackState> {
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url) async {
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
@@ -127,7 +130,22 @@ class TrackNotifier extends Notifier<TrackState> {
final type = parsed['type'] as String;
final metadata = await PlatformBridge.getSpotifyMetadata(url);
// Use the new fallback-enabled method
Map<String, dynamic> metadata;
try {
// ignore: avoid_print
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) {
// If fallback also fails, show error
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow;
}
if (!_isRequestValid(requestId)) return; // Request cancelled
if (type == 'track') {
@@ -149,6 +167,8 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
// Pre-warm cache for album tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
@@ -160,6 +180,8 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
// Pre-warm cache for playlist tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
@@ -180,7 +202,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(String query) async {
Future<void> search(String query, {String? metadataSource}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
@@ -188,14 +210,63 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
if (!_isRequestValid(requestId)) return; // Request cancelled
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
_log.i('Search started: source=$source, query="$query"');
Map<String, dynamic> results;
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
} else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
}
if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
return;
}
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
// Parse tracks with error handling per item
final tracks = <Track>[];
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(
tracks: tracks,
@@ -203,9 +274,9 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
hasSearchText: state.hasSearchText,
);
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
// Preserve hasSearchText on error so user stays on search screen
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_log.e('Search failed: $e', e, stackTrace);
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
@@ -266,7 +337,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
@@ -274,18 +345,27 @@ class TrackNotifier extends Notifier<TrackState> {
}
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(
id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: data['images']?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
releaseDate: data['release_date']?.toString(),
);
}
@@ -310,6 +390,28 @@ class TrackNotifier extends Notifier<TrackState> {
popularity: data['popularity'] as int? ?? 0,
);
}
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) {
// Only pre-warm if we have tracks with ISRC
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
// Build request list for Go backend
final cacheRequests = tracksWithIsrc.map((t) => {
'isrc': t.isrc!,
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal', // Default to tidal for pre-warming
}).toList();
// Fire and forget - runs in background
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
// Silently ignore errors - this is just an optimization
});
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+111 -38
View File
@@ -71,8 +71,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
final url = 'https://open.spotify.com/album/${widget.albumId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
Map<String, dynamic> metadata;
// Check if this is a Deezer album ID (format: "deezer:123456")
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
} else {
// Spotify album - use fallback method
// ignore: avoid_print
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
@@ -104,7 +118,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
@@ -302,8 +316,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -317,8 +331,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (tracks == null || tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
} else {
@@ -327,44 +341,69 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
),
);
@@ -455,6 +494,40 @@ class _QualityOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+46 -9
View File
@@ -69,10 +69,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true);
try {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
List<ArtistAlbum> albums;
// Check if this is a Deezer artist ID (format: "deezer:123456")
if (widget.artistId.startsWith('deezer:')) {
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
} else {
// Spotify artist - use fallback method
// ignore: avoid_print
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
}
// Store in cache
_ArtistCache.set(widget.artistId, albums);
@@ -144,6 +159,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
// Validate image URL - must be non-null, non-empty, and have a valid host
final hasValidImage = widget.coverUrl != null &&
widget.coverUrl!.isNotEmpty &&
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
return SliverAppBar(
expandedHeight: 280,
pinned: true,
@@ -154,8 +174,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
if (hasValidImage)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -177,8 +204,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
),
child: ClipOval(
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
child: hasValidImage
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 280,
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
),
)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
@@ -290,7 +325,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text(
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks',
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
+573
View File
@@ -0,0 +1,573 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
/// Screen to display downloaded tracks from a specific album
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
final String? coverUrl;
const DownloadedAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverUrl,
});
@override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
final albumKey = '${widget.albumName}|${widget.artistName}';
return itemKey == albumKey;
}).toList()
..sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<DownloadHistoryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
historyNotifier.removeFromHistory(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
);
}
}
}
Future<void> _openFile(String filePath) async {
try {
await OpenFilex.open(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
));
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
// Watch history and get tracks for this album (reactive!)
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final tracks = _getAlbumTracks(allHistoryItems);
// Auto-pop if album has less than 2 tracks (no longer an "album")
if (tracks.length < 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context);
});
return const SizedBox.shrink();
}
// Clean up selected IDs that no longer exist
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
],
),
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
),
],
),
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
),
);
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
if (tracks.isEmpty) return null;
final firstQuality = tracks.first.quality;
if (firstQuality == null) return null;
for (final track in tracks) {
if (track.quality != firstQuality) return null;
}
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: null,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
),
),
);
}
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, -4),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
Row(
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$selectedCount selected',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? 'All tracks selected' : 'Tap tracks to select',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
TextButton.icon(
onPressed: () {
if (allSelected) {
_exitSelectionMode();
} else {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? 'Deselect' : 'Select All'),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
],
),
),
),
);
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
await ref.read(trackProvider.notifier).search(url);
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
}
}
+267 -67
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -81,7 +82,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (_lastSearchQuery == query) return;
_lastSearchQuery = query;
await ref.read(trackProvider.notifier).search(query);
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -112,7 +114,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
await ref.read(trackProvider.notifier).fetchFromUrl(url);
_navigateToDetailIfNeeded();
} else {
await ref.read(trackProvider.notifier).search(url);
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -170,8 +173,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -181,64 +184,187 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityPickerOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
icon: Icons.music_note,
onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
icon: Icons.high_quality,
onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
icon: Icons.four_k,
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); },
),
const SizedBox(height: 16),
],
),
_QualityPickerOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
icon: Icons.music_note,
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
icon: Icons.high_quality,
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
icon: Icons.four_k,
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
),
const SizedBox(height: 16),
],
),
),
),
);
}
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
Widget build(BuildContext context) {
super.build(context);
@@ -262,6 +388,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final colorScheme = Theme.of(context).colorScheme;
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
final screenHeight = MediaQuery.of(context).size.height;
final topPadding = MediaQuery.of(context).padding.top;
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
return Scaffold(
@@ -270,24 +397,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
slivers: [
// App Bar - always present
SliverAppBar(
expandedHeight: 130,
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.3,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Home',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Home',
style: TextStyle(
fontSize: 20 + (14 * expandRatio), // 20 -> 34
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
@@ -302,12 +437,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
children: [
SizedBox(height: screenHeight * 0.06),
Container(
padding: const EdgeInsets.all(24),
width: 96,
height: 96,
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary, // Tint with onPrimary color
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
// Fallback to original logo if transparent one is missing
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
fit: BoxFit.cover,
),
),
),
),
const SizedBox(height: 16),
Text(
@@ -624,6 +774,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
// Validate image URL - must be non-null, non-empty, and have a valid host
final hasValidImage = artist.imageUrl != null &&
artist.imageUrl!.isNotEmpty &&
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
return GestureDetector(
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
child: Container(
@@ -639,12 +794,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
color: colorScheme.surfaceContainerHighest,
),
child: ClipOval(
child: artist.imageUrl != null
child: hasValidImage
? CachedNetworkImage(
imageUrl: artist.imageUrl!,
fit: BoxFit.cover,
memCacheWidth: 200,
memCacheHeight: 200,
errorWidget: (context, url, error) => Icon(
Icons.person,
color: colorScheme.onSurfaceVariant,
size: 44,
),
)
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
),
@@ -709,12 +869,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
onPressed: _clearAndRefresh,
tooltip: 'Clear',
)
else
else ...[
IconButton(
icon: const Icon(Icons.file_upload_outlined),
onPressed: () => _importCsv(context, ref),
tooltip: 'Import CSV',
),
IconButton(
icon: const Icon(Icons.paste),
onPressed: _pasteFromClipboard,
tooltip: 'Paste',
),
],
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
@@ -764,6 +930,40 @@ class _QualityPickerOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+4 -1
View File
@@ -62,6 +62,9 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _handleSharedUrl(String url) {
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
Navigator.of(context).popUntil((route) => route.isFirst);
// Navigate to Home tab
if (_currentIndex != 0) {
_onNavTap(0);
@@ -85,7 +88,7 @@ class _MainShellState extends ConsumerState<MainShell> {
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
final updateInfo = await UpdateChecker.checkForUpdate();
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
if (updateInfo != null && mounted) {
showUpdateDialog(
context,
+91 -32
View File
@@ -168,8 +168,8 @@ class PlaylistScreen extends ConsumerWidget {
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -182,8 +182,8 @@ class PlaylistScreen extends ConsumerWidget {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: playlistName);
} else {
@@ -192,41 +192,66 @@ class PlaylistScreen extends ConsumerWidget {
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
),
);
@@ -254,6 +279,40 @@ class _QualityOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+982 -390
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_searchController = TextEditingController(text: widget.query);
if (widget.query.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(trackProvider.notifier).search(widget.query);
final settings = ref.read(settingsProvider);
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
});
}
}
@@ -37,7 +38,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
void _search() {
final query = _searchController.text.trim();
if (query.isNotEmpty) {
ref.read(trackProvider.notifier).search(query);
final settings = ref.read(settingsProvider);
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
}
}
+190 -62
View File
@@ -12,53 +12,46 @@ class AboutPage extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
// When collapsed (expandRatio=0): left=56 to align with back button
// When expanded (expandRatio=1): left=24 for normal padding
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text(
'About',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
// When collapsed (expandRatio=0): left=56 to avoid back button
// When expanded (expandRatio=1): left=24 for normal padding
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'About',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},
);
},
),
),
),
// App header card with logo and description
SliverToBoxAdapter(
@@ -85,6 +78,49 @@ class AboutPage extends StatelessWidget {
name: AppInfo.originalAuthor,
description: 'Creator of the original SpotiFLAC',
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,
),
],
@@ -130,6 +166,24 @@ class AboutPage extends StatelessWidget {
),
),
// Support section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Support'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.coffee_outlined,
title: 'Buy me a coffee',
subtitle: 'Support development on Ko-fi',
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
],
),
),
// App info section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
@@ -166,6 +220,7 @@ class AboutPage extends StatelessWidget {
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
);
}
@@ -194,30 +249,26 @@ class _AppHeaderCard extends StatelessWidget {
padding: const EdgeInsets.all(24),
child: Column(
children: [
// App logo
// App logo
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.2),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Icon(
Icons.music_note,
size: 48,
color: colorScheme.onPrimaryContainer,
child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary, // Tint with onPrimary color
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 88,
height: 88,
fit: BoxFit.cover,
),
),
),
@@ -362,3 +413,80 @@ class _ContributorItem extends StatelessWidget {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
/// Settings item with 40x40 icon area to align with contributor avatars
class _AboutSettingsItem extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback? onTap;
final bool showDivider;
const _AboutSettingsItem({
required this.icon,
required this.title,
this.subtitle,
this.onTap,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// Icon with 40x40 size to match avatar
SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 76, // 20 + 40 + 16 = 76 (same as contributor item)
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
+555 -171
View File
@@ -14,120 +14,463 @@ class AppearanceSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
topPadding: topPadding,
),
),
// Preview Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: _ThemePreviewCard(),
),
),
// Color section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Color'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.wallpaper,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref
.read(themeProvider.notifier)
.setUseDynamicColor(value),
showDivider: false,
),
],
),
),
if (!themeSettings.useDynamicColor)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _ColorPalettePicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
// Theme section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Theme'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) =>
ref.read(themeProvider.notifier).setThemeMode(mode),
),
if (Theme.of(context).brightness == Brightness.dark)
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background',
value: themeSettings.useAmoled,
onChanged: (value) =>
ref.read(themeProvider.notifier).setUseAmoled(value),
showDivider: false,
),
],
),
),
// Layout section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Layout'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref
.read(settingsProvider.notifier)
.setHistoryViewMode(mode),
),
],
),
),
// Fill remaining for scroll
const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(height: 32),
),
],
),
),
);
}
}
/// 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: Text('Appearance',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
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,
),
);
},
),
),
// Theme section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
],
),
),
),
// Color section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.auto_awesome,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
showDivider: !themeSettings.useDynamicColor,
),
if (!themeSettings.useDynamicColor)
_ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
],
),
),
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
],
),
),
);
}
}
class _ColorPalettePicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPalettePicker({
required this.currentColor,
required this.onColorSelected,
});
static const _colors = [
Color(0xFF1DB954),
Color(0xFF6750A4),
Color(0xFF0061A4),
Color(0xFF006E1C),
Color(0xFFBA1A1A),
Color(0xFF984061),
Color(0xFF7D5260),
Color(0xFF006874),
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => onColorSelected(color),
child: _ColorPaletteItem(color: color, isSelected: isSelected),
),
);
}).toList(),
),
);
}
}
class _ColorPaletteItem extends StatelessWidget {
final Color color;
final bool isSelected;
const _ColorPaletteItem({required this.color, required this.isSelected});
@override
Widget build(BuildContext context) {
final scheme = ColorScheme.fromSeed(
seedColor: color,
brightness: Theme.of(context).brightness,
);
final size = 64.0;
return Stack(
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Expanded(
child: Row(
children: [
Expanded(child: Container(color: scheme.primaryContainer)),
Expanded(child: Container(color: scheme.tertiaryContainer)),
],
),
),
Expanded(
child: Row(
children: [
Expanded(
child: Container(color: scheme.secondaryContainer),
),
Expanded(child: Container(color: scheme.surfaceContainer)),
],
),
),
],
),
),
if (isSelected)
Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(Icons.check, size: 16, color: scheme.primary),
),
),
),
],
);
}
}
/// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget {
final String title;
final double topPadding;
const _AppBarTitle({required this.title, required this.topPadding});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
title,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
);
}
}
class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged;
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
const _ThemeModeSelector({
required this.currentMode,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
]),
child: Row(
children: [
_ThemeModeChip(
icon: Icons.brightness_auto,
label: 'System',
isSelected: currentMode == ThemeMode.system,
onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.light_mode,
label: 'Light',
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.dark_mode,
label: 'Dark',
isSelected: currentMode == ThemeMode.dark,
onTap: () => onChanged(ThemeMode.dark),
),
],
),
);
}
}
@@ -137,27 +480,41 @@ class _ThemeModeChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
const _ThemeModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
border: !isDark && !isSelected
? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null,
),
child: Material(
@@ -168,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
@@ -183,49 +556,13 @@ class _ThemeModeChip extends StatelessWidget {
}
}
class _ColorPicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPicker({required this.currentColor, required this.onColorSelected});
static const _colors = [
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () => onColorSelected(color),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 44, height: 44,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
),
);
}).toList()),
]),
);
}
}
class _HistoryViewSelector extends StatelessWidget {
final String currentMode;
final ValueChanged<String> onChanged;
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
const _HistoryViewSelector({
required this.currentMode,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
@@ -237,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(
'History View',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Row(
children: [
_ViewModeChip(
icon: Icons.view_list,
label: 'List',
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
icon: Icons.grid_view,
label: 'Grid',
isSelected: currentMode == 'grid',
onTap: () => onChanged('grid'),
),
],
),
Row(children: [
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
const SizedBox(width: 8),
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
]),
],
),
);
@@ -255,25 +609,39 @@ class _ViewModeChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
const _ViewModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
border: !isDark && !isSelected
? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null,
),
child: Material(
@@ -284,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
+550 -164
View File
@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -13,128 +15,162 @@ class DownloadSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('Download',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Download',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},
);
},
),
),
),
// Service section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ServiceSelector(
currentService: settings.defaultService,
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
),
],
// Service section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Service'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ServiceSelector(
currentService: settings.defaultService,
onChanged: (service) => ref
.read(settingsProvider.notifier)
.setDefaultService(service),
),
],
),
),
),
// Quality section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
subtitle: 'Choose quality for each download',
value: settings.askQualityBeforeDownload,
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value),
),
if (!settings.askQualityBeforeDownload) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
// Quality section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Audio Quality'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
subtitle: 'Choose quality for each download',
value: settings.askQualityBeforeDownload,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'),
if (!settings.askQualityBeforeDownload) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
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(
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'),
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,
),
],
],
),
),
),
// 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),
),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
onTap: () => _pickDirectory(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)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -142,33 +178,285 @@ class DownloadSettingsPage extends ConsumerWidget {
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
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(
context: context, isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
context: context,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
const SizedBox(height: 16),
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 24),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
]),
]),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
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),
],
),
),
),
),
),
);
}
Future<void> _pickDirectory(WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
if (Platform.isIOS) {
// iOS: Show options dialog
_showIOSDirectoryOptions(context, ref);
} else {
// Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
}
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Download Location',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'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,
),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await getApplicationDocumentsDirectory();
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(dir.path);
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(result);
}
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'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,
),
),
),
],
),
),
),
const SizedBox(height: 8),
],
),
),
);
}
String _getFolderOrganizationLabel(String value) {
@@ -184,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;
showModalBottomSheet(
context: context,
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(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -197,39 +491,61 @@ class DownloadSettingsPage extends ConsumerWidget {
children: [
Padding(
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: 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(
title: 'None',
subtitle: 'All files in download folder',
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); },
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('none');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist',
subtitle: 'Separate folder for each artist',
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); },
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Album',
subtitle: 'Separate folder for each album',
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); },
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('album');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album',
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); },
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
@@ -242,19 +558,39 @@ class DownloadSettingsPage extends ConsumerWidget {
class _ServiceSelector extends StatelessWidget {
final String currentService;
final ValueChanged<String> onChanged;
const _ServiceSelector({required this.currentService, required this.onChanged});
const _ServiceSelector({
required this.currentService,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
_ServiceChip(icon: Icons.music_note, label: 'Tidal', 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')),
]),
child: Row(
children: [
_ServiceChip(
icon: Icons.music_note,
label: 'Tidal',
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'),
),
],
),
);
}
}
@@ -264,17 +600,25 @@ class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
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
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
@@ -284,13 +628,29 @@ class _ServiceChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
@@ -304,7 +664,13 @@ class _QualityOption extends StatelessWidget {
final bool isSelected;
final VoidCallback onTap;
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
Widget build(BuildContext context) {
@@ -324,11 +690,16 @@ class _QualityOption extends StatelessWidget {
children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge),
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.circle_outlined, color: colorScheme.outline),
],
@@ -354,7 +725,13 @@ class _FolderOption extends StatelessWidget {
final String example;
final bool isSelected;
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
Widget build(BuildContext context) {
@@ -367,10 +744,19 @@ class _FolderOption extends StatelessWidget {
children: [
Text(subtitle),
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,
);
}
+801
View File
@@ -0,0 +1,801 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class LogScreen extends StatefulWidget {
const LogScreen({super.key});
@override
State<LogScreen> createState() => _LogScreenState();
}
class _LogScreenState extends State<LogScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
String _selectedLevel = 'ALL';
String _searchQuery = '';
bool _autoScroll = true;
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
@override
void initState() {
super.initState();
LogBuffer().addListener(_onLogUpdate);
// Start polling Go backend logs
LogBuffer().startGoLogPolling();
}
@override
void dispose() {
LogBuffer().removeListener(_onLogUpdate);
// Stop polling when leaving screen
LogBuffer().stopGoLogPolling();
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onLogUpdate() {
if (mounted) {
setState(() {});
if (_autoScroll && _scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
});
}
}
}
List<LogEntry> get _filteredLogs {
return LogBuffer().filter(
level: _selectedLevel,
search: _searchQuery.isEmpty ? null : _searchQuery,
);
}
void _copyLogs() {
final logs = LogBuffer().export();
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Logs copied to clipboard'),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
}
void _shareLogs() {
final logs = LogBuffer().export();
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
}
void _clearLogs() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
LogBuffer().clear();
Navigator.pop(context);
},
child: const Text('Clear'),
),
],
),
);
}
Color _getLevelColor(String level, ColorScheme colorScheme) {
switch (level) {
case 'ERROR':
case 'FATAL':
return colorScheme.error;
case 'WARN':
return Colors.orange;
case 'INFO':
return colorScheme.primary;
case 'DEBUG':
default:
return colorScheme.onSurfaceVariant;
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final logs = _filteredLogs;
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
// Collapsing App Bar with back button - same as other settings pages
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'share',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Share logs'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Clear logs'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Logs',
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Filter section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Filter'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
// Level filter
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
'Filter logs by severity',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<String>(
value: _selectedLevel,
underline: const SizedBox(),
items: _levels.map((level) {
return DropdownMenuItem(
value: level,
child: Text(
level,
style: TextStyle(
color: level == 'ALL'
? colorScheme.onSurface
: _getLevelColor(level, colorScheme),
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() => _selectedLevel = value);
}
},
),
],
),
),
Divider(
height: 1,
indent: 56,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search logs...',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
),
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
),
],
),
),
],
),
),
// Log entries section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
),
),
// Error summary card - shows detected issues
SliverToBoxAdapter(
child: _LogSummaryCard(logs: LogBuffer().entries),
),
// Log list
logs.isEmpty
? SliverToBoxAdapter(
child: SettingsGroup(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.article_outlined,
size: 48,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'No logs yet',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Logs will appear here as you use the app',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
],
),
)
: SliverToBoxAdapter(
child: SettingsGroup(
children: [
...logs.asMap().entries.map((entry) {
final index = entry.key;
final log = entry.value;
return _LogEntryTile(
entry: log,
levelColor: _getLevelColor(log.level, colorScheme),
showDivider: index < logs.length - 1,
);
}),
],
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
}
class _LogEntryTile extends StatelessWidget {
final LogEntry entry;
final Color levelColor;
final bool showDivider;
const _LogEntryTile({
required this.entry,
required this.levelColor,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isError = entry.level == 'ERROR' || entry.level == 'FATAL';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: isError
? colorScheme.errorContainer.withValues(alpha: 0.2)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: time, level, tag
Row(
children: [
Text(
entry.formattedTime,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
entry.level,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: levelColor,
),
),
),
if (entry.isFromGo) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.teal.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Go',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.teal,
),
),
),
],
const SizedBox(width: 8),
Expanded(
child: Text(
entry.tag,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
// Message
Text(
entry.message,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: colorScheme.onSurface,
height: 1.4,
),
),
// Error if present
if (entry.error != null) ...[
const SizedBox(height: 4),
Text(
entry.error!,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: colorScheme.error,
height: 1.3,
),
),
],
],
),
),
if (showDivider)
Divider(
height: 1,
indent: 20,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
/// Summary card showing detected issues in logs
class _LogSummaryCard extends StatelessWidget {
final List<LogEntry> logs;
const _LogSummaryCard({required this.logs});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Analyze logs for issues
final analysis = _analyzeLogs();
// Don't show if no issues detected
if (!analysis.hasIssues) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Card(
elevation: 0,
color: analysis.hasISPBlocking
? colorScheme.errorContainer.withValues(alpha: 0.5)
: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded,
size: 20,
color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary,
),
const SizedBox(width: 8),
Text(
'Issue Summary',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 12),
// ISP Blocking detected
if (analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.block,
label: 'ISP BLOCKING DETECTED',
description: 'Your ISP may be blocking access to download services',
suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
color: colorScheme.error,
domains: analysis.blockedDomains,
),
const SizedBox(height: 8),
],
// Rate limiting
if (analysis.hasRateLimit) ...[
_IssueBadge(
icon: Icons.speed,
label: 'RATE LIMITED',
description: 'Too many requests to the service',
suggestion: 'Wait a few minutes before trying again',
color: Colors.orange,
),
const SizedBox(height: 8),
],
// Network errors
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.wifi_off,
label: 'NETWORK ERROR',
description: 'Connection issues detected',
suggestion: 'Check your internet connection',
color: colorScheme.tertiary,
),
const SizedBox(height: 8),
],
// Track not found
if (analysis.hasNotFound) ...[
_IssueBadge(
icon: Icons.search_off,
label: 'TRACK NOT FOUND',
description: 'Some tracks could not be found on download services',
suggestion: 'The track may not be available in lossless quality',
color: colorScheme.onSurfaceVariant,
),
],
// Error count
const SizedBox(height: 12),
Text(
'Total errors: ${analysis.errorCount}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
_LogAnalysis _analyzeLogs() {
int errorCount = 0;
bool hasISPBlocking = false;
bool hasRateLimit = false;
bool hasNetworkError = false;
bool hasNotFound = false;
final Set<String> blockedDomains = {};
for (final log in logs) {
if (log.level == 'ERROR' || log.level == 'FATAL') {
errorCount++;
}
final msgLower = log.message.toLowerCase();
final errorLower = (log.error ?? '').toLowerCase();
final combined = '$msgLower $errorLower';
// Check for ISP blocking (detected by Go backend)
if (combined.contains('isp blocking') ||
combined.contains('isp may be') ||
combined.contains('blocked by isp') ||
combined.contains('connection reset') ||
combined.contains('connection refused')) {
hasISPBlocking = true;
// Try to extract domain
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!);
}
}
// Check for rate limiting
if (combined.contains('rate limit') ||
combined.contains('429') ||
combined.contains('too many requests')) {
hasRateLimit = true;
}
// Check for network errors
if (combined.contains('connection') ||
combined.contains('timeout') ||
combined.contains('network') ||
combined.contains('dial')) {
hasNetworkError = true;
}
// Check for not found
if (combined.contains('not found') ||
combined.contains('no results') ||
combined.contains('could not find')) {
hasNotFound = true;
}
}
return _LogAnalysis(
errorCount: errorCount,
hasISPBlocking: hasISPBlocking,
hasRateLimit: hasRateLimit,
hasNetworkError: hasNetworkError,
hasNotFound: hasNotFound,
blockedDomains: blockedDomains.toList(),
);
}
}
class _LogAnalysis {
final int errorCount;
final bool hasISPBlocking;
final bool hasRateLimit;
final bool hasNetworkError;
final bool hasNotFound;
final List<String> blockedDomains;
_LogAnalysis({
required this.errorCount,
required this.hasISPBlocking,
required this.hasRateLimit,
required this.hasNetworkError,
required this.hasNotFound,
required this.blockedDomains,
});
bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound;
}
class _IssueBadge extends StatelessWidget {
final IconData icon;
final String label;
final String description;
final String suggestion;
final Color color;
final List<String>? domains;
const _IssueBadge({
required this.icon,
required this.label,
required this.description,
required this.suggestion,
required this.color,
this.domains,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 6),
Text(
description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
),
if (domains != null && domains!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Affected: ${domains!.join(", ")}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontSize: 11,
),
),
],
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary),
const SizedBox(width: 4),
Expanded(
child: Text(
suggestion,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
),
],
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+29 -13
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget {
@@ -13,29 +14,38 @@ class SettingsTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return CustomScrollView(
slivers: [
// Collapsing App Bar
SliverAppBar(
expandedHeight: 130,
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.3,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Settings',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Settings',
style: TextStyle(
fontSize: 20 + (14 * expandRatio), // 20 -> 34
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
@@ -67,10 +77,16 @@ class SettingsTab extends ConsumerWidget {
),
),
// Second group: About
// Second group: Logs & About
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.article_outlined,
title: 'Logs',
subtitle: 'View app logs for debugging',
onTap: () => _navigateTo(context, const LogScreen()),
),
SettingsItem(
icon: Icons.info_outline,
title: 'About',
+454 -100
View File
@@ -23,9 +23,15 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
String? _selectedDirectory;
bool _isLoading = false;
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
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
@override
void initState() {
@@ -33,6 +39,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_initDeviceInfo();
}
@override
void dispose() {
_clientIdController.dispose();
_clientSecretController.dispose();
super.dispose();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
@@ -205,29 +218,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
setState(() => _isLoading = true);
try {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
);
if (selectedDirectory != null) {
setState(() => _selectedDirectory = selectedDirectory);
if (Platform.isIOS) {
// iOS: Show options dialog
await _showIOSDirectoryOptions();
} else {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
// Android: Use file picker
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
if (selectedDirectory != null) {
setState(() => _selectedDirectory = selectedDirectory);
} else {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
}
}
}
}
@@ -236,6 +255,82 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
}
Future<void> _showIOSDirectoryOptions() async {
final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'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),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await _getDefaultDirectory();
setState(() => _selectedDirectory = dir);
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
setState(() => _selectedDirectory = result);
}
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
const SizedBox(width: 12),
Expanded(
child: Text(
'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),
),
),
],
),
),
),
const SizedBox(height: 8),
],
),
),
);
}
Future<String> _getDefaultDirectory() async {
if (Platform.isIOS) {
final appDir = await getApplicationDocumentsDirectory();
@@ -276,6 +371,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
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();
if (mounted) {
@@ -354,8 +466,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildStepIndicator(ColorScheme colorScheme) {
final steps = _androidSdkVersion >= 33
? ['Storage', 'Notification', 'Folder']
: ['Permission', 'Folder'];
? ['Storage', 'Notification', 'Folder', 'Spotify']
: ['Permission', 'Folder', 'Spotify'];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -379,48 +491,61 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
final isActive = _currentStep >= step;
final isCompleted = _isStepCompleted(step);
final isCurrent = _currentStep == step;
return Column(
children: [
Container(
width: 32,
height: 32,
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted
? 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: isCompleted
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
? Icon(Icons.check_rounded, size: 20, color: colorScheme.onPrimary)
: Text('${step + 1}',
style: TextStyle(
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold)),
color: isCurrent ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 14,
)),
),
),
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(label,
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) {
if (_androidSdkVersion >= 33) {
// 3 steps: Storage, Notification, Folder
// 4 steps: Storage, Notification, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _notificationPermissionGranted;
case 2: return _selectedDirectory != null;
case 3: return false; // Spotify step never shows checkmark (optional)
}
} else {
// 2 steps: Permission, Folder
// 3 steps: Permission, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null;
case 2: return false; // Spotify step never shows checkmark (optional)
}
}
return false;
@@ -432,11 +557,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildNotificationPermissionStep(colorScheme);
case 2: return _buildDirectoryStep(colorScheme);
case 3: return _buildSpotifyApiStep(colorScheme);
}
} else {
switch (_currentStep) {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildDirectoryStep(colorScheme);
case 2: return _buildSpotifyApiStep(colorScheme);
}
}
return const SizedBox();
@@ -447,35 +574,50 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
size: 56,
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
// Icon with container background (M3 style)
Container(
width: 80,
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(
_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,
),
const SizedBox(height: 8),
Text(
_storagePermissionGranted
? 'You can now proceed to the next step.'
: '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,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_storagePermissionGranted
? 'You can now proceed to the next step.'
: '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)
FilledButton.icon(
onPressed: _isLoading ? null : _requestStoragePermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.security),
: const Icon(Icons.security_rounded),
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)),
),
),
],
);
@@ -486,39 +628,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
size: 56,
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
// Icon with container background (M3 style)
Container(
width: 80,
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(
_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,
),
const SizedBox(height: 8),
Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: '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,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: '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) ...[
FilledButton.icon(
onPressed: _isLoading ? null : _requestNotificationPermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.notifications_active),
: const Icon(Icons.notifications_active_rounded),
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),
TextButton(
onPressed: _skipNotificationPermission,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Skip for now'),
),
],
@@ -531,51 +691,226 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_selectedDirectory != null ? Icons.folder : Icons.create_new_folder,
size: 56,
color: _selectedDirectory != null ? colorScheme.primary : colorScheme.onSurfaceVariant,
// Icon with container background (M3 style)
Container(
width: 80,
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(
_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,
),
const SizedBox(height: 8),
if (_selectedDirectory != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder, color: colorScheme.primary, size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(_selectedDirectory!,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis),
),
],
Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_rounded, color: colorScheme.primary, size: 20),
const SizedBox(width: 12),
Flexible(
child: Text(
_selectedDirectory!,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
)
else
Text('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: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'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(
onPressed: _isLoading ? null : _selectDirectory,
icon: _isLoading
? SizedBox(width: 20, height: 20,
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'),
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(),
),
],
);
@@ -584,6 +919,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildNavigationButtons(ColorScheme colorScheme) {
final isLastStep = _currentStep == _totalSteps - 1;
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -592,8 +931,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (_currentStep > 0)
TextButton.icon(
onPressed: () => setState(() => _currentStep--),
icon: const Icon(Icons.arrow_back),
icon: const Icon(Icons.arrow_back_rounded),
label: const Text('Back'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
)
else
const SizedBox(width: 100),
@@ -602,20 +944,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (!isLastStep)
FilledButton(
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(
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
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
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Row(
: Row(
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),
],
),
),
],
+113 -81
View File
@@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final file = File(widget.item.filePath);
final exists = await file.exists();
int? size;
if (exists) {
try {
size = await file.length();
} catch (_) {}
}
if (mounted) {
setState(() {
_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;
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
Widget build(BuildContext context) {
@@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track name
// Track name (from file metadata)
Text(
item.trackName,
trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
@@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 4),
// Artist name
// Artist name (from file metadata)
Text(
item.artistName,
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(height: 8),
// Album name
// Album name (from file metadata)
Row(
children: [
Icon(
@@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(width: 8),
Expanded(
child: Text(
item.albumName,
albumName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -340,19 +353,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Metadata grid
_buildMetadataGrid(context, colorScheme),
// Spotify link button
// Streaming service link button
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _openSpotifyUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Open in Spotify'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
Builder(
builder: (context) {
final isDeezer = item.spotifyId!.contains('deezer');
return OutlinedButton.icon(
onPressed: () => _openServiceUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
),
],
],
@@ -361,16 +379,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Future<void> _openSpotifyUrl(BuildContext context) async {
Future<void> _openServiceUrl(BuildContext context) async {
if (item.spotifyId == null) return;
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
final isDeezer = item.spotifyId!.contains('deezer');
final rawId = item.spotifyId!.replaceAll('deezer:', '');
final webUrl = isDeezer
? 'https://www.deezer.com/track/$rawId'
: 'https://open.spotify.com/track/$rawId';
final appUri = isDeezer
? Uri.parse('deezer://www.deezer.com/track/$rawId')
: Uri.parse('spotify:track:$rawId');
try {
// Try to open in Spotify app first using URI scheme
// Try to open in App first using URI scheme
final launched = await launchUrl(
spotifyUri,
appUri,
mode: LaunchMode.externalApplication,
);
@@ -393,7 +419,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (context.mounted) {
_copyToClipboard(context, webUrl);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify URL copied to clipboard')),
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
);
}
}
@@ -401,31 +427,43 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Build audio quality string from file metadata
String? audioQualityStr;
if (bitDepth != null && sampleRate != null) {
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
}
final items = <_MetadataItem>[
_MetadataItem('Track name', item.trackName),
_MetadataItem('Artist', item.artistName),
if (item.albumArtist != null && item.albumArtist != item.artistName)
_MetadataItem('Album artist', item.albumArtist!),
_MetadataItem('Album', item.albumName),
if (item.trackNumber != null)
_MetadataItem('Track number', item.trackNumber.toString()),
if (item.discNumber != null && item.discNumber! > 1)
_MetadataItem('Disc number', item.discNumber.toString()),
_MetadataItem('Track name', trackName),
_MetadataItem('Artist', artistName),
if (albumArtist != null && albumArtist != artistName)
_MetadataItem('Album artist', albumArtist!),
_MetadataItem('Album', albumName),
if (trackNumber != null && trackNumber! > 0)
_MetadataItem('Track number', trackNumber.toString()),
if (discNumber != null && discNumber! > 0)
_MetadataItem('Disc number', discNumber.toString()),
if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)),
if (item.quality != null && item.quality!.contains('bit'))
_MetadataItem('Audio quality', item.quality!),
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
_MetadataItem('Release date', item.releaseDate!),
if (item.isrc != null && item.isrc!.isNotEmpty)
_MetadataItem('ISRC', item.isrc!),
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
_MetadataItem('Spotify ID', item.spotifyId!),
if (item.quality != null && item.quality!.isNotEmpty)
_MetadataItem('Quality', _formatQuality(item.quality!)),
if (audioQualityStr != null)
_MetadataItem('Audio quality', audioQualityStr),
if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem('Release date', releaseDate!),
if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', isrc!),
];
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
final isDeezer = item.spotifyId!.contains('deezer');
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
}
items.addAll([
_MetadataItem('Service', item.service.toUpperCase()),
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
];
]);
return Column(
children: items.map((metadata) {
@@ -476,32 +514,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return '$minutes:${secs.toString().padLeft(2, '0')}';
}
String _formatQuality(String quality) {
switch (quality) {
case 'LOSSLESS':
return 'Lossless (16-bit)';
case 'HI_RES':
return 'Hi-Res (24-bit)';
case 'HI_RES_LOSSLESS':
return 'Hi-Res Lossless (24-bit)';
default:
return quality;
}
}
String _formatQualityShort(String quality) {
switch (quality) {
case 'LOSSLESS':
return '16-bit';
case 'HI_RES':
return '24-bit';
case 'HI_RES_LOSSLESS':
return 'Hi-Res';
default:
return quality;
}
}
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
final fileName = item.filePath.split(Platform.pathSeparator).last;
final 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(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -578,7 +590,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20),
),
child: Text(
_formatQualityShort(item.quality!),
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
@@ -759,17 +771,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
try {
// Add timeout to prevent infinite loading
final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '',
item.trackName,
item.artistName,
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
).timeout(
const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout
);
if (mounted) {
if (result.isEmpty) {
setState(() {
_lyricsError = 'Lyrics not found';
_lyricsError = 'Lyrics not available for this track';
_lyricsLoading = false;
});
} else {
@@ -783,8 +799,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
} catch (e) {
if (mounted) {
final errorMsg = e.toString().contains('TimeoutException')
? 'Request timed out. Try again later.'
: 'Failed to load lyrics';
setState(() {
_lyricsError = 'Failed to load lyrics';
_lyricsError = errorMsg;
_lyricsLoading = false;
});
}
@@ -884,7 +903,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
ListTile(
leading: Icon(Icons.delete, color: colorScheme.error),
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)),
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, ref, colorScheme);
@@ -901,10 +920,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove from history?'),
title: const Text('Remove from device?'),
content: const Text(
'This will remove the track from your download history. '
'The downloaded file will not be deleted.',
'This will permanently delete the downloaded file and remove it from your history.',
),
actions: [
TextButton(
@@ -912,12 +930,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: const Text('Cancel'),
),
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);
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)),
),
],
),

Some files were not shown because too many files have changed in this diff Show More