Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a | |||
| 51b2ad5c77 | |||
| d641a517b8 | |||
| 608fa2ca74 | |||
| 343b309314 | |||
| 0787b32dd8 | |||
| 6927fdf7a9 | |||
| fe6af34478 | |||
| 85bb67da47 | |||
| 794486a200 | |||
| 8ce5e958ee | |||
| 5c6bf02f1c | |||
| 852335f794 | |||
| b87de1f00a | |||
| 8fcb389bb2 | |||
| 08bca30fcd | |||
| a7c5afdd20 | |||
| 5eac386eba | |||
| d35d60ac7d | |||
| 7c43d4bf70 | |||
| 2043370b6c | |||
| 39ddb7a14f | |||
| bd9b527161 | |||
| 39bcc2c547 | |||
| 973c2e3b41 | |||
| 62805720da | |||
| 0d8234ccd2 | |||
| 0edd616c3d | |||
| 9ca0e8cf5c | |||
| 37b8682faa | |||
| 6563f0f2b3 | |||
| 562fd4d7bb | |||
| 7aa3e77df1 | |||
| 4caa803eb2 | |||
| 6d5c9d0f91 | |||
| 1b2ad4cdd5 | |||
| 33e8ddd758 | |||
| d227d57545 | |||
| db1439e08f | |||
| 47e7850ee0 | |||
| 3ea665dab4 | |||
| bd4acdf222 |
@@ -1,75 +0,0 @@
|
|||||||
name: Android Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-android:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Java
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'temurin'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.21'
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Install gomobile
|
|
||||||
run: |
|
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
|
||||||
gomobile init
|
|
||||||
|
|
||||||
- name: Build Go backend for Android
|
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
|
||||||
mkdir -p ../android/app/libs
|
|
||||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: 'stable'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate app icons
|
|
||||||
run: dart run flutter_launcher_icons
|
|
||||||
|
|
||||||
- name: Build APK (Release)
|
|
||||||
run: flutter build apk --release
|
|
||||||
|
|
||||||
- name: Build App Bundle (Release)
|
|
||||||
run: flutter build appbundle --release
|
|
||||||
|
|
||||||
- name: Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-Android-APK
|
|
||||||
path: build/app/outputs/flutter-apk/app-release.apk
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload AAB artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-Android-AAB
|
|
||||||
path: build/app/outputs/bundle/release/app-release.aab
|
|
||||||
retention-days: 30
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Auto Release on Version Bump
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'pubspec.yaml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version_changed: ${{ steps.check.outputs.changed }}
|
|
||||||
new_version: ${{ steps.check.outputs.version }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check if version changed
|
|
||||||
id: check
|
|
||||||
run: |
|
|
||||||
# Get current version
|
|
||||||
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
|
||||||
|
|
||||||
# Get previous version
|
|
||||||
git show HEAD~1:pubspec.yaml > /tmp/old_pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > /tmp/old_pubspec.yaml
|
|
||||||
PREVIOUS_VERSION=$(grep '^version:' /tmp/old_pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
|
||||||
|
|
||||||
echo "Current version: $CURRENT_VERSION"
|
|
||||||
echo "Previous version: $PREVIOUS_VERSION"
|
|
||||||
|
|
||||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
|
||||||
echo "Version changed!"
|
|
||||||
echo "changed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=v$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "Version unchanged"
|
|
||||||
echo "changed=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
create-tag-and-trigger-release:
|
|
||||||
needs: check-version
|
|
||||||
if: needs.check-version.outputs.version_changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag ${{ needs.check-version.outputs.new_version }}
|
|
||||||
git push origin ${{ needs.check-version.outputs.new_version }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Trigger Release workflow
|
|
||||||
run: |
|
|
||||||
gh workflow run release.yml -f version=${{ needs.check-version.outputs.new_version }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Auto Tag on Version Change
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
- name: Get current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Current version: $VERSION"
|
||||||
|
|
||||||
|
- name: Get previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
git checkout HEAD~1 -- pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > pubspec.yaml.old
|
||||||
|
if [ -f pubspec.yaml.old ]; then
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml.old | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
else
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
fi
|
||||||
|
git checkout HEAD -- pubspec.yaml
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Previous version: $VERSION"
|
||||||
|
|
||||||
|
- name: Check if version changed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
CURRENT="${{ steps.current.outputs.version }}"
|
||||||
|
PREVIOUS="${{ steps.previous.outputs.version }}"
|
||||||
|
|
||||||
|
if [ "$CURRENT" != "$PREVIOUS" ]; then
|
||||||
|
echo "Version changed from $PREVIOUS to $CURRENT"
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Version unchanged: $CURRENT"
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check if tag exists
|
||||||
|
id: tag_exists
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
|
||||||
|
echo "Tag $TAG already exists"
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Tag $TAG does not exist"
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
if: steps.check.outputs.changed == 'true' && steps.tag_exists.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "Release $TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
echo "Created and pushed tag: $TAG"
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
name: iOS Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-ios:
|
|
||||||
runs-on: macos-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.21'
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
|
||||||
|
|
||||||
- name: Install gomobile
|
|
||||||
run: |
|
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
|
||||||
gomobile init
|
|
||||||
|
|
||||||
- name: Build Go backend for iOS (XCFramework)
|
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
|
||||||
mkdir -p ../ios/Frameworks
|
|
||||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
|
|
||||||
- name: Verify XCFramework created
|
|
||||||
run: |
|
|
||||||
echo "=== Checking XCFramework ==="
|
|
||||||
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'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate app icons
|
|
||||||
run: dart run flutter_launcher_icons
|
|
||||||
|
|
||||||
- name: Build iOS (no codesign)
|
|
||||||
run: flutter build ios --release --no-codesign
|
|
||||||
|
|
||||||
- name: Create IPA (unsigned)
|
|
||||||
run: |
|
|
||||||
mkdir -p build/ios/ipa
|
|
||||||
cd build/ios/iphoneos
|
|
||||||
mkdir Payload
|
|
||||||
cp -r Runner.app Payload/
|
|
||||||
zip -r ../ipa/SpotiFLAC-unsigned.ipa Payload
|
|
||||||
rm -rf Payload
|
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-iOS-unsigned
|
|
||||||
path: build/ios/ipa/SpotiFLAC-unsigned.ipa
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload XCFramework artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Gobackend-XCFramework
|
|
||||||
path: ios/Frameworks/Gobackend.xcframework
|
|
||||||
retention-days: 30
|
|
||||||
@@ -17,14 +17,26 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
else
|
else
|
||||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
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
|
||||||
|
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected pre-release version: $VERSION"
|
||||||
|
else
|
||||||
|
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected stable version: $VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Android and iOS build in PARALLEL
|
# Android and iOS build in PARALLEL
|
||||||
@@ -33,6 +45,20 @@ jobs:
|
|||||||
needs: get-version
|
needs: get-version
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
# Remove large unused tools (~15GB total)
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo rm -rf /usr/local/share/boost
|
||||||
|
sudo rm -rf /usr/share/swift
|
||||||
|
sudo rm -rf /usr/local/.ghcup
|
||||||
|
# Clean docker images
|
||||||
|
sudo docker image prune --all --force
|
||||||
|
# Show available space
|
||||||
|
df -h
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -86,16 +112,29 @@ jobs:
|
|||||||
- name: Generate app icons
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build APK (Release)
|
- name: Build APK (Release - unsigned)
|
||||||
run: flutter build apk --release --split-per-abi
|
run: flutter build apk --release --split-per-abi
|
||||||
|
|
||||||
|
- name: Sign APKs
|
||||||
|
uses: r0adkll/sign-android-release@v1
|
||||||
|
id: sign_arm64
|
||||||
|
with:
|
||||||
|
releaseDirectory: build/app/outputs/flutter-apk
|
||||||
|
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
alias: ${{ secrets.KEY_ALIAS }}
|
||||||
|
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
env:
|
||||||
|
BUILD_TOOLS_VERSION: "34.0.0"
|
||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cd build/app/outputs/flutter-apk
|
cd build/app/outputs/flutter-apk
|
||||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
# Signed files have -signed suffix
|
||||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||||
|
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
@@ -222,6 +261,34 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: changelog
|
||||||
|
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"
|
||||||
|
CHANGELOG="See CHANGELOG.md for details."
|
||||||
|
else
|
||||||
|
echo "Found changelog content"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save to file for multiline support
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
|
echo "Extracted changelog:"
|
||||||
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -234,26 +301,47 @@ jobs:
|
|||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
|
- name: Prepare release body
|
||||||
|
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
|
||||||
|
|
||||||
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
||||||
|
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||||
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
FOOTER
|
||||||
|
|
||||||
|
echo "Release body:"
|
||||||
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
body: |
|
body_path: /tmp/release_body.txt
|
||||||
## SpotiFLAC ${{ needs.get-version.outputs.version }}
|
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- **Android (arm64)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm64.apk` (recommended)
|
|
||||||
- **Android (arm32)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm32.apk` (older devices)
|
|
||||||
- **iOS**: `SpotiFLAC-${{ needs.get-version.outputs.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
|
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -7,8 +7,48 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# Kiro specs (optional - remove if you want to track specs)
|
# Kiro specs (development only)
|
||||||
# .kiro/
|
.kiro/
|
||||||
|
|
||||||
# Reference folder (if you don't want to include it)
|
# Reference folder (development only)
|
||||||
# referensi/
|
referensi/
|
||||||
|
|
||||||
|
# Development notes
|
||||||
|
COMPARISON_PC_vs_ANDROID.md
|
||||||
|
|
||||||
|
# Old spotiflac_android folder (moved to root)
|
||||||
|
spotiflac_android/
|
||||||
|
|
||||||
|
# Flutter/Dart
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
*.lock
|
||||||
|
!pubspec.lock
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.metadata
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
# Go backend build artifacts
|
||||||
|
go_backend/*.aar
|
||||||
|
go_backend/*.jar
|
||||||
|
go_backend/*.exe
|
||||||
|
go_backend/*.xcframework/
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/.gradle/
|
||||||
|
android/app/libs/
|
||||||
|
android/local.properties
|
||||||
|
android/*.iml
|
||||||
|
android/key.properties
|
||||||
|
android/*.jks
|
||||||
|
android/*.keystore
|
||||||
|
android/app/*.jks
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios/Frameworks/
|
||||||
|
ios/Pods/
|
||||||
|
ios/.symlinks/
|
||||||
|
ios/Flutter/Flutter.framework/
|
||||||
|
ios/Flutter/Flutter.podspec
|
||||||
|
|||||||
@@ -1,5 +1,450 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
|
||||||
|
- **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
|
||||||
|
- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
|
||||||
|
- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
|
||||||
|
|
||||||
|
## [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")
|
||||||
|
- Tertiary color highlight for Hi-Res (24-bit) downloads
|
||||||
|
- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability
|
||||||
|
- **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
|
||||||
|
- Qobuz uses track metadata for quality info
|
||||||
|
- Amazon now reads quality from downloaded FLAC file (previously returned unknown)
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
- Container with `primaryContainer` background for each option
|
||||||
|
- Distinct icons: music_note (Lossless), high_quality (Hi-Res), four_k (Max)
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Multi-Layer Caching System**: Aggressive caching to minimize API calls
|
||||||
|
- Go backend cache: Artist (10 min), Album (10 min), Search (5 min)
|
||||||
|
- Flutter memory cache: Instant navigation for previously viewed artists/albums
|
||||||
|
- Duplicate search prevention: Same query won't trigger new API call
|
||||||
|
- **Real-time Download Status**: Track items show live download progress
|
||||||
|
- Queued: Hourglass icon
|
||||||
|
- Downloading: Circular progress with percentage
|
||||||
|
- Completed: Check icon
|
||||||
|
- Works in Home search, Album, and Playlist screens
|
||||||
|
- **Downloaded Track Indicator**: Tracks already in history show check mark
|
||||||
|
- Lazy file verification: Only checks file existence when tapped
|
||||||
|
- Auto-removes from history if file was deleted, allowing re-download
|
||||||
|
- Prevents accidental duplicate downloads
|
||||||
|
- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags
|
||||||
|
- 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**:
|
||||||
|
- 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)
|
||||||
|
- Track items with dividers and InkWell ripple effect
|
||||||
|
- **Larger UI Elements**: Improved touch targets and visual hierarchy
|
||||||
|
- Recent downloads: Album art 56→100px, section height 80→130px
|
||||||
|
- Artist cards: Avatar 72→88px, container 90→100px
|
||||||
|
- Track items: Album art 48→56px
|
||||||
|
- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search
|
||||||
|
- **Smoother Progress Animation**: Progress jumps to 100% after download completes
|
||||||
|
- Embedding (cover, metadata, lyrics) happens in background without blocking UI
|
||||||
|
- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata
|
||||||
|
- Distinct icon (edit_note) with tertiary color
|
||||||
|
- User knows download is complete, just processing metadata
|
||||||
|
- **Consistent Download Button Sizes**: All download/status buttons now 44x44px
|
||||||
|
- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color
|
||||||
|
- Settings cards use overlay colors for better contrast
|
||||||
|
- Theme/view mode chips have visible borders in light mode
|
||||||
|
- **Navigation Bar Styling**: Distinct background color from content area
|
||||||
|
- **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
|
||||||
|
- Uses Riverpod `select()` for granular state watching
|
||||||
|
- Prevents entire list rebuild on progress updates
|
||||||
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download complete
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- Card-based info section with rounded corners (20px radius)
|
||||||
|
- Tonal download buttons with circular shape
|
||||||
|
- Quality picker bottom sheet with drag handle
|
||||||
|
- **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
|
||||||
|
- Search results stay on Home tab for quick downloads
|
||||||
|
- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
|
||||||
|
- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
|
||||||
|
- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
|
||||||
|
- **Stream Error Handling**: Share intent stream now has proper error handling
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- Changed launch mode to `singleTask` to reuse existing activity instead of restarting
|
||||||
|
- Added `onNewIntent` handler to properly receive new share intents
|
||||||
|
- **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
|
||||||
|
- Works for both single track and batch downloads
|
||||||
|
- **Live Search**: Search results appear as you type with 400ms debounce
|
||||||
|
- Animated search bar moves from center to top when typing
|
||||||
|
- Keyboard stays open during transition
|
||||||
|
- Back button navigates through search history (album → artist → idle)
|
||||||
|
- Clear button to reset search
|
||||||
|
- URLs still require manual submit
|
||||||
|
- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs
|
||||||
|
- **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
|
||||||
|
- Better contrast with white overlay for dark mode dynamic colors
|
||||||
|
- **Larger Tab Titles**: Increased app bar title size (28px) and height (130px) for better visibility
|
||||||
|
- **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
|
||||||
|
- `flutter_local_notifications`: 18.0.1 → 19.0.0
|
||||||
|
- `build_runner`: 2.4.15 → 2.10.4
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- Works with both `open.spotify.com` URLs and `spotify:` URIs
|
||||||
|
- **Lyrics Viewer**: View lyrics for downloaded tracks in Track Metadata screen
|
||||||
|
- Fetches lyrics from LRCLIB on-demand
|
||||||
|
- Clean display without timestamps
|
||||||
|
- Copy lyrics to clipboard
|
||||||
|
- **Artist URL Support**: Paste artist URL to browse their discography
|
||||||
|
- Shows all albums, singles, and compilations
|
||||||
|
- Horizontal scrollable album cards grouped by type
|
||||||
|
- Tap any album to view and download its tracks
|
||||||
|
- **Folder Organization**: Organize downloads into folders by artist or album
|
||||||
|
- Options: None, By Artist, By Album, By Artist & Album
|
||||||
|
- Configurable in Settings > Download
|
||||||
|
- **Japanese Lyrics to Romaji**: Auto-convert Hiragana/Katakana lyrics to romaji
|
||||||
|
- Useful for non-Japanese speakers who want to sing along
|
||||||
|
- Toggle in Settings > Options > Lyrics
|
||||||
|
- Kanji characters are preserved (requires dictionary lookup)
|
||||||
|
- **History View Mode**: Choose between grid or list view for download history
|
||||||
|
- Grid view shows album art in a 3-column layout (default)
|
||||||
|
- List view shows detailed track info with date
|
||||||
|
- Configurable in Settings > Appearance > Layout
|
||||||
|
- **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
|
||||||
|
- Cleaner separation between active downloads and history
|
||||||
|
- **Smarter Back Navigation**: Back button now navigates properly
|
||||||
|
- Goes back through search history (album → artist → empty)
|
||||||
|
- Returns to Search tab from other tabs
|
||||||
|
- 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
|
||||||
|
- **Back Navigation**: Android back button now works as expected - returns to previous view (album → artist → empty search)
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
- Downloads correct APK for your device
|
||||||
|
- **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
|
||||||
|
- Summary notification when all downloads complete
|
||||||
|
- **Notification Permission in Setup**: Android 13+ users will be prompted for notification permission during initial setup
|
||||||
|
- New step in setup wizard for notification permission
|
||||||
|
- Option to skip if user doesn't want notifications
|
||||||
|
- **Per-Item Queue Controls**: Each track in download queue now has individual controls
|
||||||
|
- Cancel button for queued items
|
||||||
|
- Stop button for currently downloading items
|
||||||
|
- Retry and Remove buttons for failed/skipped items
|
||||||
|
- Visual progress bar with percentage for each downloading track
|
||||||
|
- **Pull-to-Refresh on Home**: Swipe down to clear URL input and fetched tracks
|
||||||
|
- No need to exit app to clear current search/fetch
|
||||||
|
- **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage
|
||||||
|
- Previously concurrent downloads jumped from 0% to 100%
|
||||||
|
- Now each track shows real-time progress when downloading in parallel
|
||||||
|
- **In-App Update**: Download and install updates directly from the app
|
||||||
|
- Progress bar shows download status
|
||||||
|
- Automatic device architecture detection (arm64/arm32)
|
||||||
|
- 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
|
||||||
|
- Better visual hierarchy with cover art, track info, and action buttons
|
||||||
|
- **Settings UI**: Redesigned with category-based navigation (One UI style)
|
||||||
|
- Main settings tab with 4 categories: Appearance, Download, Options, About
|
||||||
|
- Each category opens a detail page
|
||||||
|
- Large title at top with menu items below
|
||||||
|
- One-handed friendly layout
|
||||||
|
- **Collapsing Toolbar**: Implemented One UI style collapsing header for all tabs
|
||||||
|
- Title animates from 28px (expanded) to 20px (collapsed)
|
||||||
|
- Back button only on settings detail pages
|
||||||
|
- Consistent across Home, Downloads, and Settings tabs
|
||||||
|
- **Home Search Bar Redesign**: More prominent and user-friendly input
|
||||||
|
- Larger card-style search bar with border outline
|
||||||
|
- Tap to open bottom sheet with full input experience
|
||||||
|
- Paste and Search buttons clearly visible
|
||||||
|
- Helper text showing supported URL types
|
||||||
|
- **Empty State Improved**: Better onboarding for new users
|
||||||
|
- "Ready to Download" title with icon
|
||||||
|
- Clear instructions on how to use the app
|
||||||
|
- "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
|
||||||
|
- Added multi-progress tracking in Go backend (`ItemProgress`, `ItemProgressWriter`)
|
||||||
|
- Added `GetAllDownloadProgress`, `InitItemProgress`, `FinishItemProgress`, `ClearItemProgress` exports
|
||||||
|
- 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
|
||||||
|
- Smoother animations: Changed to `BouncingScrollPhysics` and `Curves.easeOutCubic`
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- Displays: track name, artist, album artist, album, track number, disc number, duration, release date, ISRC, Spotify ID, quality, service, download date
|
||||||
|
- File info: format (FLAC/M4A), file size, quality badge, service badge with colors
|
||||||
|
- Tap to copy ISRC and Spotify ID
|
||||||
|
- "Open in Spotify" button to open track in Spotify app/browser
|
||||||
|
- File path display with copy functionality
|
||||||
|
- Play and Delete action buttons
|
||||||
|
- **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)
|
||||||
|
- Removed unused `history_screen.dart` and `history_tab.dart` files
|
||||||
|
|
||||||
|
## [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
|
## [1.1.0] - 2026-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -11,7 +456,6 @@
|
|||||||
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
|
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
|
||||||
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
|
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
|
||||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||||
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
|
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||||
<img src="docs/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
|
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||||
<img src="docs/Screenshot_20260101-210633_SpotiFLAC.png" width="200" />
|
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||||
<img src="docs/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
@@ -29,6 +29,8 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load keystore properties for local builds
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = java.util.Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -22,6 +29,17 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -30,8 +48,6 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
// Only include arm64-v8a for smaller APK (most modern devices)
|
|
||||||
// Remove this line if you need to support older 32-bit devices
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
}
|
}
|
||||||
@@ -39,8 +55,13 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// For local builds: use release signing if key.properties exists
|
||||||
// Enable code shrinking and resource shrinking
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -72,7 +93,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
implementation(files("libs/gobackend.aar"))
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
|||||||
@@ -4,26 +4,30 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
<!-- For Android 11+ (API 30-32) - full storage access -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -76,6 +80,17 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- FileProvider for APK installation -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileProvider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
|
|||||||
@@ -5,83 +5,211 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationCompat
|
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.
|
||||||
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "spotiflac_download_channel"
|
private const val CHANNEL_ID = "download_channel"
|
||||||
const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1001
|
||||||
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
|
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||||
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
|
|
||||||
|
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
|
||||||
|
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
|
||||||
|
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
|
||||||
|
|
||||||
|
const val EXTRA_TRACK_NAME = "track_name"
|
||||||
|
const val EXTRA_ARTIST_NAME = "artist_name"
|
||||||
|
const val EXTRA_PROGRESS = "progress"
|
||||||
|
const val EXTRA_TOTAL = "total"
|
||||||
|
const val EXTRA_QUEUE_COUNT = "queue_count"
|
||||||
|
|
||||||
|
private var isRunning = false
|
||||||
|
|
||||||
|
fun isServiceRunning(): Boolean = isRunning
|
||||||
|
|
||||||
|
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_PROGRESS
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_PROGRESS, progress)
|
||||||
|
putExtra(EXTRA_TOTAL, total)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var currentTrackName = ""
|
||||||
|
private var currentArtistName = ""
|
||||||
|
private var queueCount = 0
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_START -> startForegroundService()
|
ACTION_START -> {
|
||||||
ACTION_STOP -> stopSelf()
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
|
||||||
|
startForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_UPDATE_PROGRESS -> {
|
||||||
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
|
||||||
|
val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
|
||||||
|
val total = intent.getLongExtra(EXTRA_TOTAL, 0)
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
updateNotification(progress, total)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Download Progress",
|
"Download Service",
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW
|
||||||
).apply {
|
).apply {
|
||||||
description = "Shows download progress for SpotiFLAC"
|
description = "Shows download progress"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
val notification = createNotification("Downloading...", 0)
|
isRunning = true
|
||||||
|
|
||||||
|
// Acquire wake lock to prevent CPU sleep
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = powerManager.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
WAKELOCK_TAG
|
||||||
|
).apply {
|
||||||
|
acquire(60 * 60 * 1000L) // 1 hour max
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = buildNotification(0, 0)
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProgress(trackName: String, progress: Int) {
|
private fun stopForegroundService() {
|
||||||
val notification = createNotification(trackName, progress)
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(progress: Long, total: Long) {
|
||||||
|
if (!isRunning) return
|
||||||
|
|
||||||
|
val notification = buildNotification(progress, total)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.notify(NOTIFICATION_ID, notification)
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(title: String, progress: Int): Notification {
|
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
this, 0, intent,
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
val stopIntent = Intent(this, DownloadService::class.java).apply {
|
val title = if (queueCount > 1) {
|
||||||
action = ACTION_STOP
|
"Downloading $queueCount tracks"
|
||||||
|
} else if (currentTrackName.isNotEmpty()) {
|
||||||
|
currentTrackName
|
||||||
|
} else {
|
||||||
|
"Downloading..."
|
||||||
}
|
}
|
||||||
val stopPendingIntent = PendingIntent.getService(
|
|
||||||
this, 0, stopIntent,
|
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
currentArtistName
|
||||||
)
|
} else if (total > 0) {
|
||||||
|
val progressPercent = (progress * 100 / total).toInt()
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
val progressMB = progress / (1024.0 * 1024.0)
|
||||||
.setContentTitle("SpotiFLAC")
|
val totalMB = total / (1024.0 * 1024.0)
|
||||||
.setContentText(title)
|
String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
|
||||||
|
} else {
|
||||||
|
"Preparing download..."
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
.setProgress(100, progress, progress == 0)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
|
.setOngoing(true)
|
||||||
.build()
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
builder.setProgress(100, (progress * 100 / total).toInt(), false)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -14,6 +15,12 @@ class MainActivity: FlutterActivity() {
|
|||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// Update the intent so receive_sharing_intent can access the new data
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
@@ -43,6 +50,15 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"searchSpotifyAll" -> {
|
||||||
|
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.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"checkAvailability" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -71,6 +87,33 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getAllDownloadProgress" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getAllDownloadProgress()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"initItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.initItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"finishItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.finishItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"clearItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"setDownloadDirectory" -> {
|
"setDownloadDirectory" -> {
|
||||||
val path = call.argument<String>("path") ?: ""
|
val path = call.argument<String>("path") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -114,8 +157,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -133,6 +177,37 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"startDownloadService" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.start(this@MainActivity, trackName, artistName, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopDownloadService" -> {
|
||||||
|
DownloadService.stop(this@MainActivity)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"updateDownloadServiceProgress" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val progress = call.argument<Long>("progress") ?: 0L
|
||||||
|
val total = call.argument<Long>("total") ?: 0L
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"isDownloadServiceRunning" -> {
|
||||||
|
result.success(DownloadService.isServiceRunning())
|
||||||
|
}
|
||||||
|
"setSpotifyCredentials" -> {
|
||||||
|
val clientId = call.argument<String>("client_id") ?: ""
|
||||||
|
val clientSecret = call.argument<String>("client_secret") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
</paths>
|
||||||
@@ -10,10 +10,19 @@ subprojects {
|
|||||||
if (project.hasProperty("android")) {
|
if (project.hasProperty("android")) {
|
||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable multidex for all subprojects
|
||||||
|
defaultConfig {
|
||||||
|
multiDexEnabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add desugaring dependency to all Android subprojects
|
||||||
|
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 202 KiB |
@@ -36,6 +36,63 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
} `json:"current"`
|
} `json:"current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
fmt.Printf("[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 using DoubleDouble service
|
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
return &AmazonDownloader{
|
||||||
@@ -202,11 +259,12 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,8 +284,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -236,51 +294,73 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Track download progress
|
// Use item progress writer
|
||||||
pw := NewProgressWriter(out)
|
var bytesWritten int64
|
||||||
_, err = io.Copy(pw, resp.Body)
|
if itemID != "" {
|
||||||
|
pw := NewItemProgressWriter(out, itemID)
|
||||||
|
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
bytesWritten, err = io.Copy(out, resp.Body)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonDownloadResult contains download result with quality info
|
||||||
|
type AmazonDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
// Uses DoubleDouble service (same as PC version)
|
// Uses DoubleDouble service (same as PC version)
|
||||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if needed
|
// Create output directory if needed
|
||||||
if req.OutputDir != "." {
|
if req.OutputDir != "." {
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download using DoubleDouble service (same as PC)
|
// Download using DoubleDouble service (same as PC)
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
|
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log match found
|
||||||
|
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -295,12 +375,19 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
@@ -359,5 +446,24 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
return outputPath, nil
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
return AmazonDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: quality.BitDepth,
|
||||||
|
SampleRate: quality.SampleRate,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,12 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||||
|
SetSpotifyCredentials(clientID, clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||||
// Returns JSON with track/album/playlist data
|
// Returns JSON with track/album/playlist data
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
@@ -70,6 +77,26 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchSpotifyAll searches for tracks and artists on Spotify
|
||||||
|
// Returns JSON with tracks and artists arrays
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// CheckAvailability checks track availability on streaming services
|
// CheckAvailability checks track availability on streaming services
|
||||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
@@ -99,12 +126,15 @@ type DownloadRequest struct {
|
|||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
FilenameFormat string `json:"filename_format"`
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
// DownloadResponse represents the result of a download
|
||||||
@@ -114,6 +144,17 @@ type DownloadResponse struct {
|
|||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
|
type DownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -132,16 +173,40 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
@@ -151,21 +216,44 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "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{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -207,35 +295,82 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "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{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -254,6 +389,27 @@ func GetDownloadProgress() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
|
||||||
|
func GetAllDownloadProgress() string {
|
||||||
|
return GetMultiProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitItemProgress initializes progress tracking for a download item
|
||||||
|
func InitItemProgress(itemID string) {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishItemProgress marks a download item as complete and removes tracking
|
||||||
|
func FinishItemProgress(itemID string) {
|
||||||
|
CompleteItemProgress(itemID)
|
||||||
|
// Don't remove immediately - let Flutter poll one more time to see 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearItemProgress removes progress tracking for a specific item
|
||||||
|
func ClearItemProgress(itemID string) {
|
||||||
|
RemoveItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
// CleanupConnections closes idle HTTP connections
|
// CleanupConnections closes idle HTTP connections
|
||||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||||
func CleanupConnections() {
|
func CleanupConnections() {
|
||||||
@@ -323,14 +479,24 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) {
|
// 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)
|
||||||
|
if filePath != "" {
|
||||||
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
|
if err == nil && lyrics != "" {
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to fetching from internet
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
lrcContent := convertToLRC(lyricsData)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing
|
// Remove existing (case-insensitive comparison for Vorbis comments)
|
||||||
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
comment := cmt.Comments[i]
|
||||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
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
|
// Add new
|
||||||
@@ -335,3 +342,92 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||||
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.VorbisComment {
|
||||||
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
|
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] != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
|
type AudioQuality struct {
|
||||||
|
BitDepth int `json:"bit_depth"`
|
||||||
|
SampleRate int `json:"sample_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read FLAC marker (4 bytes: "fLaC")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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)
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
// DownloadProgress represents current download progress
|
||||||
|
// Now unified - returns data from multi-progress system
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
CurrentFile string `json:"current_file"`
|
CurrentFile string `json:"current_file"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
@@ -12,54 +14,169 @@ type DownloadProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemProgress represents progress for a single download item
|
||||||
|
type ItemProgress struct {
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiProgress holds progress for multiple concurrent downloads
|
||||||
|
type MultiProgress struct {
|
||||||
|
Items map[string]*ItemProgress `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentProgress DownloadProgress
|
downloadDir string
|
||||||
progressMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
downloadDir string
|
|
||||||
downloadDirMu sync.RWMutex
|
// Multi-download progress tracking (unified system)
|
||||||
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
|
multiMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress
|
// getProgress returns current download progress from multi-progress system
|
||||||
|
// Returns first active item's progress for backward compatibility
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
progressMu.RLock()
|
multiMu.RLock()
|
||||||
defer progressMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
return currentProgress
|
|
||||||
|
// Find first active item
|
||||||
|
for _, item := range multiProgress.Items {
|
||||||
|
return DownloadProgress{
|
||||||
|
CurrentFile: item.ItemID,
|
||||||
|
Progress: item.Progress * 100, // Convert to percentage
|
||||||
|
BytesTotal: item.BytesTotal,
|
||||||
|
BytesReceived: item.BytesReceived,
|
||||||
|
IsDownloading: item.IsDownloading,
|
||||||
|
Status: item.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadProgress{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
// GetMultiProgress returns progress for all active downloads as JSON
|
||||||
func SetDownloadProgress(mbDownloaded float64) {
|
func GetMultiProgress() string {
|
||||||
progressMu.Lock()
|
multiMu.RLock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.RUnlock()
|
||||||
currentProgress.Progress = mbDownloaded
|
|
||||||
currentProgress.IsDownloading = true
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
|
if err != nil {
|
||||||
|
return "{\"items\":{}}"
|
||||||
|
}
|
||||||
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadSpeed sets the current download speed
|
// GetItemProgress returns progress for a specific item as JSON
|
||||||
func SetDownloadSpeed(speedMBps float64) {
|
func GetItemProgress(itemID string) string {
|
||||||
progressMu.Lock()
|
multiMu.RLock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.RUnlock()
|
||||||
currentProgress.Speed = speedMBps
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
jsonBytes, _ := json.Marshal(item)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentFile sets the current file being downloaded and resets progress
|
// StartItemProgress initializes progress tracking for an item
|
||||||
func SetCurrentFile(filename string) {
|
func StartItemProgress(itemID string) {
|
||||||
progressMu.Lock()
|
multiMu.Lock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
// Reset progress for new file
|
|
||||||
currentProgress.BytesReceived = 0
|
multiProgress.Items[itemID] = &ItemProgress{
|
||||||
currentProgress.BytesTotal = 0
|
ItemID: itemID,
|
||||||
currentProgress.Progress = 0
|
BytesTotal: 0,
|
||||||
currentProgress.CurrentFile = filename
|
BytesReceived: 0,
|
||||||
currentProgress.IsDownloading = true
|
Progress: 0,
|
||||||
|
IsDownloading: true,
|
||||||
|
Status: "downloading",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetProgress resets the download progress
|
// SetItemBytesTotal sets total bytes for an item
|
||||||
func ResetProgress() {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
progressMu.Lock()
|
multiMu.Lock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
currentProgress = DownloadProgress{}
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesTotal = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceived sets bytes received for an item
|
||||||
|
func SetItemBytesReceived(itemID string, received int64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteItemProgress marks an item as complete
|
||||||
|
func CompleteItemProgress(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = 1.0
|
||||||
|
item.IsDownloading = false
|
||||||
|
item.Status = "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemProgress sets progress for an item directly
|
||||||
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = progress
|
||||||
|
if bytesReceived > 0 {
|
||||||
|
item.BytesReceived = bytesReceived
|
||||||
|
}
|
||||||
|
if bytesTotal > 0 {
|
||||||
|
item.BytesTotal = bytesTotal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||||
|
func SetItemFinalizing(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = 1.0
|
||||||
|
item.Status = "finalizing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItemProgress removes progress tracking for an item
|
||||||
|
func RemoveItemProgress(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
delete(multiProgress.Items, itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllItemProgress clears all item progress
|
||||||
|
func ClearAllItemProgress() {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDownloadDir sets the default download directory
|
// setDownloadDir sets the default download directory
|
||||||
@@ -77,61 +194,29 @@ func getDownloadDir() string {
|
|||||||
return downloadDir
|
return downloadDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloading sets the download status
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
func SetDownloading(status bool) {
|
type ItemProgressWriter struct {
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.IsDownloading = status
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBytesTotal sets total bytes to download
|
|
||||||
func SetBytesTotal(total int64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.BytesTotal = total
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBytesReceived sets bytes received so far
|
|
||||||
func SetBytesReceived(received int64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.BytesReceived = received
|
|
||||||
if currentProgress.BytesTotal > 0 {
|
|
||||||
currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProgressWriter wraps io.Writer to track download progress
|
|
||||||
type ProgressWriter struct {
|
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
total int64
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
// Reset bytes received when starting new download
|
return &ItemProgressWriter{
|
||||||
SetBytesReceived(0)
|
|
||||||
return &ProgressWriter{
|
|
||||||
writer: w,
|
writer: w,
|
||||||
|
itemID: itemID,
|
||||||
current: 0,
|
current: 0,
|
||||||
total: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer
|
// Write implements io.Writer
|
||||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
pw.total += int64(n)
|
SetItemBytesReceived(pw.itemID, pw.current)
|
||||||
SetBytesReceived(pw.current)
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTotal returns total bytes written
|
|
||||||
func (pw *ProgressWriter) GetTotal() int64 {
|
|
||||||
return pw.total
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -39,6 +40,63 @@ type QobuzTrack struct {
|
|||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func qobuzIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// NewQobuzDownloader creates a new Qobuz downloader
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
return &QobuzDownloader{
|
return &QobuzDownloader{
|
||||||
@@ -112,8 +170,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 30 seconds tolerance
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't
|
||||||
|
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
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
|
||||||
|
fmt.Printf("[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
|
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies
|
||||||
@@ -129,6 +275,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allTracks []QobuzTrack
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
||||||
|
|
||||||
@@ -159,19 +307,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
// Return first result with best quality
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
for i := range result.Tracks.Items {
|
}
|
||||||
track := &result.Tracks.Items[i]
|
}
|
||||||
|
|
||||||
|
if len(allTracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If duration verification is requested
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationMatches = append(durationMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationMatches) > 0 {
|
||||||
|
// Return best quality among duration matches
|
||||||
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return first result if no hi-res found
|
return durationMatches[0], nil
|
||||||
return &result.Tracks.Items[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No duration match found
|
||||||
|
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
// No duration verification, return best quality
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &allTracks[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -261,11 +440,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -283,8 +463,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -293,42 +473,72 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use ProgressWriter for tracking
|
// Use item progress writer
|
||||||
progressWriter := NewProgressWriter(out)
|
if itemID != "" {
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QobuzDownloadResult contains download result with quality info
|
||||||
|
type QobuzDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
func downloadFromQobuz(req DownloadRequest) (string, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata
|
// Strategy 2: Search by metadata with duration verification
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if 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 {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log match found
|
||||||
|
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -343,18 +553,44 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map quality from Tidal format to Qobuz format
|
||||||
|
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
|
||||||
|
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
|
||||||
|
qobuzQuality := "27" // Default to highest quality
|
||||||
|
switch req.Quality {
|
||||||
|
case "LOSSLESS":
|
||||||
|
qobuzQuality = "6" // 16-bit FLAC
|
||||||
|
case "HI_RES":
|
||||||
|
qobuzQuality = "7" // 24-bit 96kHz
|
||||||
|
case "HI_RES_LOSSLESS":
|
||||||
|
qobuzQuality = "27" // 24-bit 192kHz
|
||||||
|
}
|
||||||
|
fmt.Printf("[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)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Embed metadata
|
||||||
@@ -407,5 +643,9 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputPath, nil
|
return QobuzDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: actualBitDepth,
|
||||||
|
SampleRate: actualSampleRate,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Japanese character ranges
|
|
||||||
const (
|
|
||||||
hiraganaStart = 0x3040
|
|
||||||
hiraganaEnd = 0x309F
|
|
||||||
katakanaStart = 0x30A0
|
|
||||||
katakanaEnd = 0x30FF
|
|
||||||
kanjiStart = 0x4E00
|
|
||||||
kanjiEnd = 0x9FFF
|
|
||||||
)
|
|
||||||
|
|
||||||
// hiraganaToRomaji maps hiragana characters to romaji
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
|
||||||
// Basic vowels
|
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
|
||||||
// K-row
|
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
|
||||||
// S-row
|
|
||||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
|
||||||
// T-row
|
|
||||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
|
||||||
// N-row
|
|
||||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
|
||||||
// H-row
|
|
||||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
|
||||||
// M-row
|
|
||||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
|
||||||
// Y-row
|
|
||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
|
||||||
// R-row
|
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
|
||||||
// W-row
|
|
||||||
'わ': "wa", 'を': "wo",
|
|
||||||
// N
|
|
||||||
'ん': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
|
||||||
// Z-row
|
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
|
||||||
// D-row
|
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
|
||||||
// B-row
|
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
|
||||||
// P-row (handakuten)
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
|
||||||
// Small characters
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
|
||||||
'っ': "", // Small tsu - handled specially
|
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
|
||||||
|
|
||||||
// katakanaToRomaji maps katakana characters to romaji
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
|
||||||
// Basic vowels
|
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
|
||||||
// K-row
|
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
|
||||||
// S-row
|
|
||||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
|
||||||
// T-row
|
|
||||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
|
||||||
// N-row
|
|
||||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
|
||||||
// H-row
|
|
||||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
|
||||||
// M-row
|
|
||||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
|
||||||
// Y-row
|
|
||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
|
||||||
// R-row
|
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
|
||||||
// W-row
|
|
||||||
'ワ': "wa", 'ヲ': "wo",
|
|
||||||
// N
|
|
||||||
'ン': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
|
||||||
// Z-row
|
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
|
||||||
// D-row
|
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
|
||||||
// B-row
|
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
|
||||||
// P-row (handakuten)
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
|
||||||
// Small characters
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
|
||||||
'ッ': "", // Small tsu - handled specially
|
|
||||||
// Extended katakana
|
|
||||||
'ヴ': "vu",
|
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extended katakana combinations (multi-character)
|
|
||||||
var katakanaExtended = map[string]string{
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combination mappings for small ya/yu/yo
|
|
||||||
var hiraganaCombo = 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 katakanaCombo = 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 katakana combinations
|
|
||||||
"ティ": "ti", "ディ": "di",
|
|
||||||
"トゥ": "tu", "ドゥ": "du",
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
|
||||||
"ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji)
|
|
||||||
func ContainsJapanese(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji)
|
|
||||||
func ContainsKana(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHiragana(r rune) bool {
|
|
||||||
return r >= hiraganaStart && r <= hiraganaEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKatakana(r rune) bool {
|
|
||||||
return r >= katakanaStart && r <= katakanaEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
|
||||||
return r >= kanjiStart && r <= kanjiEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
|
||||||
// Kanji characters are preserved as-is since they require dictionary lookup
|
|
||||||
func ToRomaji(s string) string {
|
|
||||||
if !ContainsKana(s) {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
runes := []rune(s)
|
|
||||||
var result strings.Builder
|
|
||||||
result.Grow(len(s) * 2) // Romaji is typically longer
|
|
||||||
|
|
||||||
i := 0
|
|
||||||
for i < len(runes) {
|
|
||||||
r := runes[i]
|
|
||||||
|
|
||||||
// Check for two-character combinations first
|
|
||||||
if i+1 < len(runes) {
|
|
||||||
combo := string(runes[i : i+2])
|
|
||||||
if romaji, ok := hiraganaCombo[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if romaji, ok := katakanaCombo[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle small tsu (っ/ッ) - doubles the next consonant
|
|
||||||
if r == 'っ' || r == 'ッ' {
|
|
||||||
if i+1 < len(runes) {
|
|
||||||
nextRune := runes[i+1]
|
|
||||||
var nextRomaji string
|
|
||||||
if romaji, ok := hiraganaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
} else if romaji, ok := katakanaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
}
|
|
||||||
if len(nextRomaji) > 0 {
|
|
||||||
result.WriteByte(nextRomaji[0]) // Double the consonant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle long vowel mark (ー)
|
|
||||||
if r == 'ー' {
|
|
||||||
// Extend the previous vowel
|
|
||||||
resultStr := result.String()
|
|
||||||
if len(resultStr) > 0 {
|
|
||||||
lastChar := resultStr[len(resultStr)-1]
|
|
||||||
if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' {
|
|
||||||
result.WriteByte(lastChar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single character conversion
|
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if romaji, ok := katakanaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep non-Japanese characters as-is
|
|
||||||
if unicode.IsSpace(r) {
|
|
||||||
result.WriteRune(' ')
|
|
||||||
} else {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRomajiVariants returns search variants for Japanese text
|
|
||||||
// Returns the original string plus romaji version if applicable
|
|
||||||
func GetRomajiVariants(s string) []string {
|
|
||||||
variants := []string{s}
|
|
||||||
|
|
||||||
if ContainsKana(s) {
|
|
||||||
romaji := ToRomaji(s)
|
|
||||||
if romaji != s && strings.TrimSpace(romaji) != "" {
|
|
||||||
variants = append(variants, romaji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return variants
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,11 +21,28 @@ const (
|
|||||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
|
// Cache TTL settings
|
||||||
|
artistCacheTTL = 10 * time.Minute
|
||||||
|
searchCacheTTL = 5 * time.Minute
|
||||||
|
albumCacheTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
|
|
||||||
|
// cacheEntry holds cached data with expiration
|
||||||
|
type cacheEntry struct {
|
||||||
|
data interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cacheEntry) isExpired() bool {
|
||||||
|
return time.Now().After(e.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
// SpotifyMetadataClient handles Spotify API interactions
|
// SpotifyMetadataClient handles Spotify API interactions
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -32,31 +50,76 @@ type SpotifyMetadataClient struct {
|
|||||||
clientSecret string
|
clientSecret string
|
||||||
cachedToken string
|
cachedToken string
|
||||||
tokenExpiresAt time.Time
|
tokenExpiresAt time.Time
|
||||||
|
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
||||||
|
// Caches to reduce API calls
|
||||||
|
artistCache map[string]*cacheEntry // key: artistID
|
||||||
|
searchCache map[string]*cacheEntry // key: query+type
|
||||||
|
albumCache map[string]*cacheEntry // key: albumID
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom credentials storage (set from Flutter)
|
||||||
|
var (
|
||||||
|
customClientID string
|
||||||
|
customClientSecret string
|
||||||
|
credentialsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
|
credentialsMu.Lock()
|
||||||
|
defer credentialsMu.Unlock()
|
||||||
|
customClientID = clientID
|
||||||
|
customClientSecret = clientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCredentials returns the current credentials (custom or default)
|
||||||
|
func getCredentials() (string, string) {
|
||||||
|
credentialsMu.RLock()
|
||||||
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
if customClientID != "" && customClientSecret != "" {
|
||||||
|
return customClientID, customClientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default credentials
|
||||||
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
if clientID == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||||
|
clientID = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
if clientSecret == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||||
|
clientSecret = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientID, clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
// Decode credentials from base64
|
// Get credentials (custom or default)
|
||||||
clientID := ""
|
clientID, clientSecret := getCredentials()
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSecret := ""
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
|
||||||
clientSecret = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c
|
||||||
@@ -131,6 +194,32 @@ type PlaylistResponsePayload struct {
|
|||||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtistInfoMetadata holds artist information
|
||||||
|
type ArtistInfoMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistAlbumMetadata holds album info for artist discography
|
||||||
|
type ArtistAlbumMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"` // album, single, compilation
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistResponsePayload is the response for artist requests
|
||||||
|
type ArtistResponsePayload struct {
|
||||||
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||||
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||||
|
}
|
||||||
|
|
||||||
// TrackResponse is the response for single track requests
|
// TrackResponse is the response for single track requests
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
Track TrackMetadata `json:"track"`
|
Track TrackMetadata `json:"track"`
|
||||||
@@ -142,6 +231,21 @@ type SearchResult struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchArtistResult represents an artist in search results
|
||||||
|
type SearchArtistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAllResult represents combined search results for tracks and artists
|
||||||
|
type SearchAllResult struct {
|
||||||
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
type spotifyURI struct {
|
type spotifyURI struct {
|
||||||
Type string
|
Type string
|
||||||
ID string
|
ID string
|
||||||
@@ -212,6 +316,8 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
|||||||
return c.fetchAlbum(ctx, parsed.ID, token)
|
return c.fetchAlbum(ctx, parsed.ID, token)
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return c.fetchPlaylist(ctx, parsed.ID, token)
|
return c.fetchPlaylist(ctx, parsed.ID, token)
|
||||||
|
case "artist":
|
||||||
|
return c.fetchArtist(ctx, parsed.ID, token)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
}
|
}
|
||||||
@@ -263,6 +369,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchAll searches for tracks and artists on Spotify
|
||||||
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
// Create cache key
|
||||||
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*SearchAllResult), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
token, err := c.getAccessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []trackFull `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
Artists struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
Followers struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
||||||
|
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range response.Tracks.Items {
|
||||||
|
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||||
|
SpotifyID: track.ID,
|
||||||
|
Artists: joinArtists(track.Artists),
|
||||||
|
Name: track.Name,
|
||||||
|
AlbumName: track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(track.Album.Artists),
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
Images: firstImageURL(track.Album.Images),
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
TotalTracks: track.Album.TotalTracks,
|
||||||
|
DiscNumber: track.DiscNumber,
|
||||||
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit artists to artistLimit
|
||||||
|
artistCount := len(response.Artists.Items)
|
||||||
|
if artistCount > artistLimit {
|
||||||
|
artistCount = artistLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < artistCount; i++ {
|
||||||
|
artist := response.Artists.Items[i]
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: artist.ID,
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: firstImageURL(artist.Images),
|
||||||
|
Followers: artist.Followers.Total,
|
||||||
|
Popularity: artist.Popularity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(searchCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
||||||
var data trackFull
|
var data trackFull
|
||||||
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
||||||
@@ -289,6 +487,14 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -344,13 +550,24 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
result := &AlbumResponsePayload{
|
||||||
AlbumInfo: info,
|
AlbumInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(albumCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
@@ -361,7 +578,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
|
Next string `json:"next"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +593,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
info.Owner.Name = data.Name
|
info.Owner.Name = data.Name
|
||||||
info.Owner.Images = firstImageURL(data.Images)
|
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 {
|
for _, item := range data.Tracks.Items {
|
||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
@@ -399,12 +620,161 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch remaining tracks using pagination (up to 1000 tracks max)
|
||||||
|
nextURL := data.Tracks.Next
|
||||||
|
maxTracks := 1000
|
||||||
|
|
||||||
|
for nextURL != "" && len(tracks) < maxTracks {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if len(tracks) >= maxTracks {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
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{
|
return &PlaylistResponsePayload{
|
||||||
PlaylistInfo: info,
|
PlaylistInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
|
// Check cache first
|
||||||
|
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
|
||||||
|
var artistData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
Followers struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistInfo := ArtistInfoMetadata{
|
||||||
|
ID: artistData.ID,
|
||||||
|
Name: artistData.Name,
|
||||||
|
Images: firstImageURL(artistData.Images),
|
||||||
|
Followers: artistData.Followers.Total,
|
||||||
|
Popularity: artistData.Popularity,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch artist albums (all types: album, single, compilation)
|
||||||
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
|
offset := 0
|
||||||
|
limit := 50
|
||||||
|
|
||||||
|
for {
|
||||||
|
albumsURL := fmt.Sprintf("%s?include_groups=album,single,compilation&limit=%d&offset=%d",
|
||||||
|
fmt.Sprintf(artistAlbumsURL, artistID), limit, offset)
|
||||||
|
|
||||||
|
var albumsData struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, albumsURL, token, &albumsData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, album := range albumsData.Items {
|
||||||
|
albums = append(albums, ArtistAlbumMetadata{
|
||||||
|
ID: album.ID,
|
||||||
|
Name: album.Name,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.TotalTracks,
|
||||||
|
Images: firstImageURL(album.Images),
|
||||||
|
AlbumType: album.AlbumType,
|
||||||
|
Artists: joinArtists(album.Artists),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are more albums
|
||||||
|
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += limit
|
||||||
|
|
||||||
|
// Safety limit to prevent infinite loops
|
||||||
|
if offset > 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ArtistResponsePayload{
|
||||||
|
ArtistInfo: artistInfo,
|
||||||
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(artistCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||||
var data struct {
|
var data struct {
|
||||||
ExternalID externalID `json:"external_ids"`
|
ExternalID externalID `json:"external_ids"`
|
||||||
|
|||||||
@@ -315,6 +315,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
||||||
|
func normalizeTitle(title string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
|
||||||
|
// Remove common suffixes in parentheses or brackets
|
||||||
|
suffixPatterns := []string{
|
||||||
|
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||||
|
" (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||||
|
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||||
|
}
|
||||||
|
for _, suffix := range suffixPatterns {
|
||||||
|
normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(normalized, " ") {
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
@@ -335,33 +357,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Romaji versions if Japanese detected
|
// Strategy 3: Artist only as last resort
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
|
||||||
// Try romaji version of track name
|
|
||||||
if ContainsKana(trackName) {
|
|
||||||
romajiTrack := ToRomaji(trackName)
|
|
||||||
if romajiTrack != trackName {
|
|
||||||
if artistName != "" {
|
|
||||||
queries = append(queries, artistName+" "+romajiTrack)
|
|
||||||
}
|
|
||||||
queries = append(queries, romajiTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try romaji version of artist name
|
|
||||||
if ContainsKana(artistName) {
|
|
||||||
romajiArtist := ToRomaji(artistName)
|
|
||||||
if romajiArtist != artistName {
|
|
||||||
queries = append(queries, romajiArtist+" "+trackName)
|
|
||||||
// Try both romaji
|
|
||||||
if ContainsKana(trackName) {
|
|
||||||
romajiTrack := ToRomaji(trackName)
|
|
||||||
queries = append(queries, romajiArtist+" "+romajiTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: Artist only as last resort
|
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
queries = append(queries, artistName)
|
queries = append(queries, artistName)
|
||||||
}
|
}
|
||||||
@@ -416,14 +412,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
return nil, fmt.Errorf("no tracks found for any search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match)
|
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
|
var isrcMatches []*TidalTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
if track.ISRC == spotifyISRC {
|
if track.ISRC == spotifyISRC {
|
||||||
return track, nil
|
isrcMatches = append(isrcMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration first (most important check)
|
||||||
|
if expectedDuration > 0 {
|
||||||
|
var durationVerifiedMatches []*TidalTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDuration
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance for duration
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
// Return first duration-verified match
|
||||||
|
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't - this is likely wrong version
|
||||||
|
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||||
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, just return first ISRC match
|
||||||
|
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
// If ISRC was provided but no match found, return error
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
@@ -483,11 +515,18 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TidalDownloadInfo contains download URL and quality info
|
||||||
|
type TidalDownloadInfo struct {
|
||||||
|
URL string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
// getDownloadURLSequential requests download URL from APIs sequentially
|
// getDownloadURLSequential requests download URL from APIs sequentially
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
|
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", "", fmt.Errorf("no APIs available")
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
@@ -519,7 +558,12 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
// Try v2 format first (object with manifest)
|
// Try v2 format first (object with manifest)
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
|
info := TidalDownloadInfo{
|
||||||
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
|
SampleRate: v2Response.Data.SampleRate,
|
||||||
|
}
|
||||||
|
return apiURL, info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||||
@@ -529,7 +573,13 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||||
for _, item := range v1Responses {
|
for _, item := range v1Responses {
|
||||||
if item.OriginalTrackURL != "" {
|
if item.OriginalTrackURL != "" {
|
||||||
return apiURL, item.OriginalTrackURL, nil
|
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: item.OriginalTrackURL,
|
||||||
|
BitDepth: 16,
|
||||||
|
SampleRate: 44100,
|
||||||
|
}
|
||||||
|
return apiURL, info, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,22 +587,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
|
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
||||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||||
apis := t.GetAvailableAPIs()
|
apis := t.GetAvailableAPIs()
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", fmt.Errorf("no API URL configured")
|
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, downloadURL, err := getDownloadURLSequential(apis, trackID, quality)
|
_, info, err := getDownloadURLSequential(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadURL, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
|
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
|
||||||
@@ -640,16 +690,17 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with progress tracking
|
// DownloadFile downloads a file from URL with progress tracking
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Handle manifest-based download
|
// Handle manifest-based download
|
||||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath)
|
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -667,8 +718,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -677,13 +728,18 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use ProgressWriter for tracking
|
// Use item progress writer
|
||||||
progressWriter := NewProgressWriter(out)
|
if itemID != "" {
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) error {
|
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||||
@@ -695,10 +751,11 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
|||||||
|
|
||||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", directURL, nil)
|
req, err := http.NewRequest("GET", directURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -716,8 +773,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -726,9 +783,14 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use ProgressWriter for tracking
|
// Use item progress writer
|
||||||
progressWriter := NewProgressWriter(out)
|
if itemID != "" {
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,15 +862,83 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TidalDownloadResult contains download result with quality info
|
||||||
|
type TidalDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistsMatch checks if the artist names are similar enough
|
||||||
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
|
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||||
|
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normSpotify == normTidal {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
||||||
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
||||||
|
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
||||||
|
|
||||||
|
tidalFirst := strings.Split(normTidal, ",")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
||||||
|
tidalFirst = strings.TrimSpace(tidalFirst)
|
||||||
|
|
||||||
|
if spotifyFirst == tidalFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||||
|
spotifyASCII := isASCIIString(spotifyArtist)
|
||||||
|
tidalASCII := isASCIIString(tidalArtist)
|
||||||
|
if spotifyASCII != tidalASCII {
|
||||||
|
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isASCIIString checks if a string contains only ASCII characters
|
||||||
|
func isASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromTidal downloads a track using the request parameters
|
// downloadFromTidal downloads a track using the request parameters
|
||||||
func downloadFromTidal(req DownloadRequest) (string, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -820,28 +950,103 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
|
if track != nil {
|
||||||
|
// Get artist name from track
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify duration if we have expected duration
|
||||||
|
if track != nil && expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff > 30 {
|
||||||
|
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
|
expectedDurationSec, track.Duration)
|
||||||
|
track = nil // Reject this match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by ISRC with multi-strategy fallback
|
// Strategy 2: Search by ISRC with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist for ISRC match too
|
||||||
|
if track != nil {
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
// Strategy 3: Search by metadata only (no ISRC requirement)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||||
|
// Verify artist for metadata search too
|
||||||
|
if track != nil {
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Tidal"
|
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("tidal search failed: %s", errMsg)
|
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final verification logging
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -856,18 +1061,35 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine quality to use (default to LOSSLESS if not specified)
|
||||||
|
quality := req.Quality
|
||||||
|
if quality == "" {
|
||||||
|
quality = "LOSSLESS"
|
||||||
|
}
|
||||||
|
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS")
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
// Log actual quality received
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
|
||||||
|
// Download file with item ID for progress tracking
|
||||||
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||||
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
||||||
@@ -880,7 +1102,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||||
} else if _, err := os.Stat(outputPath); err != nil {
|
} else if _, err := os.Stat(outputPath); err != nil {
|
||||||
// Neither FLAC nor M4A exists
|
// Neither FLAC nor M4A exists
|
||||||
return "", fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Embed metadata
|
||||||
@@ -938,5 +1160,9 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return actualOutputPath, nil
|
return TidalDownloadResult{
|
||||||
|
FilePath: actualOutputPath,
|
||||||
|
BitDepth: downloadInfo.BitDepth,
|
||||||
|
SampleRate: downloadInfo.SampleRate,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "searchSpotifyAll":
|
||||||
|
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 = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -90,6 +99,28 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendGetDownloadProgress()
|
let response = GobackendGetDownloadProgress()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getAllDownloadProgress":
|
||||||
|
let response = GobackendGetAllDownloadProgress()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "initItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendInitItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "finishItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendFinishItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "clearItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendClearItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
@@ -133,7 +164,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error)
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -145,6 +177,10 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "cleanupConnections":
|
||||||
|
GobackendCleanupConnections()
|
||||||
|
return nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final settings = ref.watch(settingsProvider);
|
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||||
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
|
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/// App version and info constants
|
||||||
|
/// Update version here only - all other files will reference this
|
||||||
|
class AppInfo {
|
||||||
|
static const String version = '2.0.6';
|
||||||
|
static const String buildNumber = '36';
|
||||||
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
static const String appName = 'SpotiFLAC';
|
||||||
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
|
static const String mobileAuthor = 'zarzet';
|
||||||
|
static const String originalAuthor = 'afkarxyz';
|
||||||
|
|
||||||
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
}
|
||||||
@@ -2,9 +2,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize notification service
|
||||||
|
await NotificationService().initialize();
|
||||||
|
|
||||||
|
// Initialize share intent service
|
||||||
|
await ShareIntentService().initialize();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
child: const _EagerInitialization(
|
child: const _EagerInitialization(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ part 'download_item.g.dart';
|
|||||||
enum DownloadStatus {
|
enum DownloadStatus {
|
||||||
queued,
|
queued,
|
||||||
downloading,
|
downloading,
|
||||||
|
finalizing, // Embedding metadata, cover, lyrics
|
||||||
completed,
|
completed,
|
||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
@@ -22,6 +23,7 @@ class DownloadItem {
|
|||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -32,6 +34,7 @@ class DownloadItem {
|
|||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadItem copyWith({
|
DownloadItem copyWith({
|
||||||
@@ -43,6 +46,7 @@ class DownloadItem {
|
|||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -53,6 +57,7 @@ class DownloadItem {
|
|||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||||
@@ -29,11 +30,13 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$DownloadStatusEnumMap = {
|
const _$DownloadStatusEnumMap = {
|
||||||
DownloadStatus.queued: 'queued',
|
DownloadStatus.queued: 'queued',
|
||||||
DownloadStatus.downloading: 'downloading',
|
DownloadStatus.downloading: 'downloading',
|
||||||
|
DownloadStatus.finalizing: 'finalizing',
|
||||||
DownloadStatus.completed: 'completed',
|
DownloadStatus.completed: 'completed',
|
||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ class AppSettings {
|
|||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||||
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
|
final String historyViewMode; // list, grid
|
||||||
|
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)
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -24,6 +32,14 @@ class AppSettings {
|
|||||||
this.maxQualityCover = true,
|
this.maxQualityCover = true,
|
||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||||
|
this.checkForUpdates = true, // Default: enabled
|
||||||
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
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
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -36,6 +52,14 @@ class AppSettings {
|
|||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
int? concurrentDownloads,
|
int? concurrentDownloads,
|
||||||
|
bool? checkForUpdates,
|
||||||
|
bool? hasSearchedBefore,
|
||||||
|
String? folderOrganization,
|
||||||
|
String? historyViewMode,
|
||||||
|
bool? askQualityBeforeDownload,
|
||||||
|
String? spotifyClientId,
|
||||||
|
String? spotifyClientSecret,
|
||||||
|
bool? useCustomSpotifyCredentials,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -47,6 +71,14 @@ class AppSettings {
|
|||||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
|
useCustomSpotifyCredentials:
|
||||||
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -29,4 +38,12 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'maxQualityCover': instance.maxQualityCover,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
|
'folderOrganization': instance.folderOrganization,
|
||||||
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
// Apply Spotify credentials to Go backend on load
|
||||||
|
_applySpotifyCredentials();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply current Spotify credentials to Go backend
|
||||||
|
Future<void> _applySpotifyCredentials() async {
|
||||||
|
// Only apply custom credentials if enabled and both fields are set
|
||||||
|
if (state.useCustomSpotifyCredentials &&
|
||||||
|
state.spotifyClientId.isNotEmpty &&
|
||||||
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
|
state.spotifyClientId,
|
||||||
|
state.spotifyClientSecret,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Clear to use default
|
||||||
|
await PlatformBridge.setSpotifyCredentials('', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
state = state.copyWith(defaultService: service);
|
state = state.copyWith(defaultService: service);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -71,6 +90,67 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(concurrentDownloads: clamped);
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setCheckForUpdates(bool enabled) {
|
||||||
|
state = state.copyWith(checkForUpdates: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHasSearchedBefore() {
|
||||||
|
if (!state.hasSearchedBefore) {
|
||||||
|
state = state.copyWith(hasSearchedBefore: true);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFolderOrganization(String organization) {
|
||||||
|
state = state.copyWith(folderOrganization: organization);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHistoryViewMode(String mode) {
|
||||||
|
state = state.copyWith(historyViewMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientId(String clientId) {
|
||||||
|
state = state.copyWith(spotifyClientId: clientId);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientSecret(String clientSecret) {
|
||||||
|
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyCredentials(String clientId, String clientSecret) {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: clientId,
|
||||||
|
spotifyClientSecret: clientSecret,
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSpotifyCredentials() {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: '',
|
||||||
|
spotifyClientSecret: '',
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUseCustomSpotifyCredentials(bool enabled) {
|
||||||
|
state = state.copyWith(useCustomSpotifyCredentials: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -6,72 +6,147 @@ class TrackState {
|
|||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final String? albumId;
|
||||||
final String? albumName;
|
final String? albumName;
|
||||||
final String? playlistName;
|
final String? playlistName;
|
||||||
|
final String? artistId;
|
||||||
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
|
final bool hasSearchText; // For back button handling
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.albumId,
|
||||||
this.albumName,
|
this.albumName,
|
||||||
this.playlistName,
|
this.playlistName,
|
||||||
|
this.artistId,
|
||||||
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.artistAlbums,
|
||||||
|
this.searchArtists,
|
||||||
|
this.hasSearchText = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||||
|
|
||||||
TrackState copyWith({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
|
String? albumId,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
String? artistId,
|
||||||
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
|
List<ArtistAlbum>? artistAlbums,
|
||||||
|
List<SearchArtist>? searchArtists,
|
||||||
|
bool? hasSearchText,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
|
albumId: albumId ?? this.albumId,
|
||||||
albumName: albumName ?? this.albumName,
|
albumName: albumName ?? this.albumName,
|
||||||
playlistName: playlistName ?? this.playlistName,
|
playlistName: playlistName ?? this.playlistName,
|
||||||
|
artistId: artistId ?? this.artistId,
|
||||||
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents an album in artist discography
|
||||||
|
class ArtistAlbum {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String releaseDate;
|
||||||
|
final int totalTracks;
|
||||||
|
final String? coverUrl;
|
||||||
|
final String albumType; // album, single, compilation
|
||||||
|
final String artists;
|
||||||
|
|
||||||
|
const ArtistAlbum({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.releaseDate,
|
||||||
|
required this.totalTracks,
|
||||||
|
this.coverUrl,
|
||||||
|
required this.albumType,
|
||||||
|
required this.artists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an artist in search results
|
||||||
|
class SearchArtist {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? imageUrl;
|
||||||
|
final int followers;
|
||||||
|
final int popularity;
|
||||||
|
|
||||||
|
const SearchArtist({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.followers,
|
||||||
|
required this.popularity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class TrackNotifier extends Notifier<TrackState> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
/// Request ID to track and cancel outdated requests
|
||||||
|
int _currentRequestId = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
return const TrackState();
|
return const 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) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during fetch
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: [track],
|
tracks: [track],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
|
||||||
playlistName: null,
|
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
);
|
);
|
||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
albumId: parsed['id'] as String?,
|
||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
playlistName: null,
|
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
);
|
);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
@@ -79,34 +154,59 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
|
||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
);
|
);
|
||||||
|
} else if (type == 'artist') {
|
||||||
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
state = TrackState(
|
||||||
|
tracks: [], // No tracks for artist view
|
||||||
|
isLoading: false,
|
||||||
|
artistId: artistInfo['id'] as String?,
|
||||||
|
artistName: artistInfo['name'] as String?,
|
||||||
|
coverUrl: artistInfo['images'] as String?,
|
||||||
|
artistAlbums: albums,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
// Preserve hasSearchText on error so user stays on search screen
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during search
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
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 tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||||
state = state.copyWith(
|
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
|
searchArtists: artists,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
hasSearchText: state.hasSearchText,
|
||||||
playlistName: null,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
// Preserve hasSearchText on error so user stays on search screen
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +252,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = const TrackState();
|
state = const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set search text state for back button handling
|
||||||
|
void setSearchText(bool hasText) {
|
||||||
|
state = state.copyWith(hasSearchText: hasText);
|
||||||
|
}
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: data['spotify_id'] as String? ?? '',
|
||||||
@@ -161,7 +266,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] 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?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -177,12 +282,34 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] 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?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
|
return ArtistAlbum(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
|
artists: data['artists'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchArtist _parseSearchArtist(Map<String, dynamic> data) {
|
||||||
|
return SearchArtist(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
imageUrl: data['images'] as String?,
|
||||||
|
followers: data['followers'] as int? ?? 0,
|
||||||
|
popularity: data['popularity'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||||
|
|||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
|
/// Simple in-memory cache for album tracks
|
||||||
|
class _AlbumCache {
|
||||||
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
static List<Track>? get(String albumId) {
|
||||||
|
final entry = _cache[albumId];
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
|
_cache.remove(albumId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set(String albumId, List<Track> tracks) {
|
||||||
|
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheEntry {
|
||||||
|
final List<Track> tracks;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
_CacheEntry(this.tracks, this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album detail screen with Material Expressive 3 design
|
||||||
|
class AlbumScreen extends ConsumerStatefulWidget {
|
||||||
|
final String albumId;
|
||||||
|
final String albumName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<Track>? tracks; // Optional - will fetch if null
|
||||||
|
|
||||||
|
const AlbumScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumId,
|
||||||
|
required this.albumName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||||
|
List<Track>? _tracks;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Priority: widget.tracks > cache > fetch
|
||||||
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
|
if (_tracks == null) {
|
||||||
|
_fetchTracks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchTracks() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
|
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
|
return Track(
|
||||||
|
id: data['spotify_id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
artistName: data['artists'] as String? ?? '',
|
||||||
|
albumName: data['album_name'] as String? ?? '',
|
||||||
|
albumArtist: data['album_artist'] as String?,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
isrc: data['isrc'] as String?,
|
||||||
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
|
trackNumber: data['track_number'] as int?,
|
||||||
|
discNumber: data['disc_number'] as int?,
|
||||||
|
releaseDate: data['release_date'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final tracks = _tracks ?? [];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme),
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
)),
|
||||||
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||||
|
_buildTrackListHeader(context, colorScheme),
|
||||||
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
final tracks = _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: 8),
|
||||||
|
if (tracks.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (tracks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _downloadAll(context),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text('Download All (${tracks.length})'),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _AlbumTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadAll(BuildContext context) {
|
||||||
|
final tracks = _tracks;
|
||||||
|
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);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error widget with special handling for rate limit (429)
|
||||||
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final isRateLimit = error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Rate Limited',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Too many requests. Please wait a moment and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error display
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
|
final String trackName;
|
||||||
|
final String? artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isOverflowing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||||
|
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||||
|
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||||
|
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _isOverflowing != titleOverflows) {
|
||||||
|
setState(() => _isOverflowing = titleOverflows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: _expanded ? 10 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.artistName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.artistName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: _expanded ? 3 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isOverflowing || _expanded)
|
||||||
|
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
class _AlbumTrackItem extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _AlbumTrackItem({required this.track, required this.onDownload});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track
|
||||||
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if track is in history (already downloaded before)
|
||||||
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
|
return state.isDownloaded(track.id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final isQueued = queueItem != null;
|
||||||
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
|
// Show as downloaded if in queue completed OR in history
|
||||||
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||||
|
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, 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: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isDownloading,
|
||||||
|
required bool isFinalizing,
|
||||||
|
required bool showAsDownloaded,
|
||||||
|
required bool isInHistory,
|
||||||
|
required double progress,
|
||||||
|
}) {
|
||||||
|
const double size = 44.0;
|
||||||
|
const double iconSize = 20.0;
|
||||||
|
|
||||||
|
if (showAsDownloaded) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isDownloading) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isQueued) {
|
||||||
|
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||||
|
} else {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onDownload,
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
|
|
||||||
|
/// Simple in-memory cache for artist discography
|
||||||
|
class _ArtistCache {
|
||||||
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
static List<ArtistAlbum>? get(String artistId) {
|
||||||
|
final entry = _cache[artistId];
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
|
_cache.remove(artistId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set(String artistId, List<ArtistAlbum> albums) {
|
||||||
|
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheEntry {
|
||||||
|
final List<ArtistAlbum> albums;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
_CacheEntry(this.albums, this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artist screen with Material Expressive 3 design - shows discography
|
||||||
|
class ArtistScreen extends ConsumerStatefulWidget {
|
||||||
|
final String artistId;
|
||||||
|
final String artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<ArtistAlbum>? albums; // Optional - will fetch if null
|
||||||
|
|
||||||
|
const ArtistScreen({
|
||||||
|
super.key,
|
||||||
|
required this.artistId,
|
||||||
|
required this.artistName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.albums,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||||
|
bool _isLoadingDiscography = false;
|
||||||
|
List<ArtistAlbum>? _albums;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Priority: widget.albums > cache > fetch
|
||||||
|
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
||||||
|
if (_albums == null) {
|
||||||
|
_fetchDiscography();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
_ArtistCache.set(widget.artistId, albums);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_albums = albums;
|
||||||
|
_isLoadingDiscography = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoadingDiscography = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
|
return ArtistAlbum(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
|
artists: data['artists'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final albums = _albums ?? [];
|
||||||
|
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||||
|
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||||
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme),
|
||||||
|
if (_isLoadingDiscography)
|
||||||
|
const SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
)),
|
||||||
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
|
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
||||||
|
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
|
||||||
|
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
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)
|
||||||
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, 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) {
|
||||||
|
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.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_albums != null)
|
||||||
|
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.album, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 210,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = albums[index];
|
||||||
|
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _navigateToAlbum(album),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: album.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
||||||
|
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToAlbum(ArtistAlbum album) {
|
||||||
|
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
||||||
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (context) => AlbumScreen(
|
||||||
|
albumId: album.id,
|
||||||
|
albumName: album.name,
|
||||||
|
coverUrl: album.coverUrl,
|
||||||
|
// tracks: null - will be fetched in AlbumScreen
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error widget with special handling for rate limit (429)
|
||||||
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final isRateLimit = error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Rate Limited',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Too many requests. Please wait a moment and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error display
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
class HistoryScreen extends ConsumerWidget {
|
|
||||||
const HistoryScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
|
||||||
final history = historyState.items;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Download History'),
|
|
||||||
actions: [
|
|
||||||
if (history.isNotEmpty)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_sweep),
|
|
||||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
|
||||||
tooltip: 'Clear history',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: history.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: history.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = history[index];
|
|
||||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No download history',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Downloaded tracks will appear here',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
final fileExists = File(item.filePath).existsSync();
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: Key(item.id),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
color: colorScheme.error,
|
|
||||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
|
||||||
),
|
|
||||||
onDismissed: (_) {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: item.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.trackName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getServiceIcon(item.service),
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatDate(item.downloadedAt),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!fileExists) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.warning,
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'File missing',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: fileExists
|
|
||||||
? IconButton(
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
onPressed: () => _openFile(context, item.filePath),
|
|
||||||
)
|
|
||||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
|
||||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
|
||||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getServiceIcon(String service) {
|
|
||||||
switch (service.toLowerCase()) {
|
|
||||||
case 'tidal':
|
|
||||||
return Icons.waves;
|
|
||||||
case 'qobuz':
|
|
||||||
return Icons.album;
|
|
||||||
case 'amazon':
|
|
||||||
return Icons.shopping_cart;
|
|
||||||
default:
|
|
||||||
return Icons.cloud_download;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final diff = now.difference(date);
|
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
|
||||||
if (diff.inHours == 0) {
|
|
||||||
return '${diff.inMinutes}m ago';
|
|
||||||
}
|
|
||||||
return '${diff.inHours}h ago';
|
|
||||||
} else if (diff.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (diff.inDays < 7) {
|
|
||||||
return '${diff.inDays}d ago';
|
|
||||||
} else {
|
|
||||||
return '${date.day}/${date.month}/${date.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
|
||||||
try {
|
|
||||||
final result = await OpenFilex.open(filePath);
|
|
||||||
|
|
||||||
if (result.type != ResultType.done) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Cannot open: ${result.message}'),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Copy Path',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: filePath));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Path copied to clipboard')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Cannot open file: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (item.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.albumName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
|
||||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
|
||||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
|
||||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
if (File(item.filePath).existsSync())
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_openFile(context, item.filePath);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: isPath ? 12 : 14,
|
|
||||||
fontFamily: isPath ? 'monospace' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear History'),
|
|
||||||
content: const Text(
|
|
||||||
'Are you sure you want to clear all download history? '
|
|
||||||
'This will not delete the downloaded files.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
class HistoryTab extends ConsumerWidget {
|
|
||||||
const HistoryTab({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
|
||||||
final history = historyState.items;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// Header with clear action
|
|
||||||
if (history.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${history.length} downloads',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
|
||||||
icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error),
|
|
||||||
label: Text('Clear history', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// History list
|
|
||||||
Expanded(
|
|
||||||
child: history.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: history.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = history[index];
|
|
||||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No download history',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Downloaded tracks will appear here',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
final fileExists = File(item.filePath).existsSync();
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: Key(item.id),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
color: colorScheme.error,
|
|
||||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
|
||||||
),
|
|
||||||
onDismissed: (_) {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: item.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.trackName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getServiceIcon(item.service),
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatDate(item.downloadedAt),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!fileExists) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.warning,
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'File missing',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: fileExists
|
|
||||||
? IconButton(
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
onPressed: () => _openFile(context, item.filePath),
|
|
||||||
)
|
|
||||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
|
||||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
|
||||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getServiceIcon(String service) {
|
|
||||||
switch (service.toLowerCase()) {
|
|
||||||
case 'tidal':
|
|
||||||
return Icons.waves;
|
|
||||||
case 'qobuz':
|
|
||||||
return Icons.album;
|
|
||||||
case 'amazon':
|
|
||||||
return Icons.shopping_cart;
|
|
||||||
default:
|
|
||||||
return Icons.cloud_download;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final diff = now.difference(date);
|
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
|
||||||
if (diff.inHours == 0) {
|
|
||||||
return '${diff.inMinutes}m ago';
|
|
||||||
}
|
|
||||||
return '${diff.inHours}h ago';
|
|
||||||
} else if (diff.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (diff.inDays < 7) {
|
|
||||||
return '${diff.inDays}d ago';
|
|
||||||
} else {
|
|
||||||
return '${date.day}/${date.month}/${date.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
|
||||||
try {
|
|
||||||
final result = await OpenFilex.open(filePath);
|
|
||||||
|
|
||||||
if (result.type != ResultType.done) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Cannot open: ${result.message}'),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Copy Path',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: filePath));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Path copied to clipboard')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Cannot open file: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (item.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.albumName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
|
||||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
|
||||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
|
||||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
if (File(item.filePath).existsSync())
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_openFile(context, item.filePath);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: isPath ? 12 : 14,
|
|
||||||
fontFamily: isPath ? 'monospace' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear History'),
|
|
||||||
content: const Text(
|
|
||||||
'Are you sure you want to clear all download history? '
|
|
||||||
'This will not delete the downloaded files.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -177,9 +177,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
onDestinationSelected: _onNavTap,
|
onDestinationSelected: _onNavTap,
|
||||||
destinations: [
|
destinations: [
|
||||||
const NavigationDestination(
|
const NavigationDestination(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: Icon(Icons.search_outlined),
|
||||||
selectedIcon: Icon(Icons.home),
|
selectedIcon: Icon(Icons.search),
|
||||||
label: 'Home',
|
label: 'Search',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
@@ -219,7 +219,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, _) => Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/settings_tab.dart';
|
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||||
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('MainShell');
|
||||||
|
|
||||||
class MainShell extends ConsumerStatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
@@ -15,90 +25,219 @@ class MainShell extends ConsumerStatefulWidget {
|
|||||||
class _MainShellState extends ConsumerState<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
|
bool _hasCheckedUpdate = false;
|
||||||
|
StreamSubscription<String>? _shareSubscription;
|
||||||
|
DateTime? _lastBackPress; // For double-tap to exit
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
// Check for updates after first frame
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkForUpdates();
|
||||||
|
_setupShareListener();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupShareListener() {
|
||||||
|
// Check for pending URL that was received before listener was ready
|
||||||
|
final pendingUrl = ShareIntentService().consumePendingUrl();
|
||||||
|
if (pendingUrl != null) {
|
||||||
|
_log.d('Processing pending shared URL: $pendingUrl');
|
||||||
|
_handleSharedUrl(pendingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for future shared URLs with error handling
|
||||||
|
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
||||||
|
(url) {
|
||||||
|
_log.d('Received shared URL from stream: $url');
|
||||||
|
_handleSharedUrl(url);
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
_log.e('Share stream error: $error');
|
||||||
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSharedUrl(String url) {
|
||||||
|
// Navigate to Home tab
|
||||||
|
if (_currentIndex != 0) {
|
||||||
|
_onNavTap(0);
|
||||||
|
}
|
||||||
|
// Fetch metadata for shared URL
|
||||||
|
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
|
// Mark that user has searched (hide helper text)
|
||||||
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
// Show snackbar
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Loading shared link...')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkForUpdates() async {
|
||||||
|
if (_hasCheckedUpdate) return;
|
||||||
|
_hasCheckedUpdate = true;
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (!settings.checkForUpdates) return;
|
||||||
|
|
||||||
|
final updateInfo = await UpdateChecker.checkForUpdate();
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
showUpdateDialog(
|
||||||
|
context,
|
||||||
|
updateInfo: updateInfo,
|
||||||
|
onDisableUpdates: () {
|
||||||
|
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_shareSubscription?.cancel();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNavTap(int index) {
|
void _onNavTap(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index) {
|
||||||
_pageController.animateToPage(
|
setState(() => _currentIndex = index);
|
||||||
index,
|
_pageController.animateToPage(
|
||||||
duration: const Duration(milliseconds: 300),
|
index,
|
||||||
curve: Curves.easeInOut,
|
duration: const Duration(milliseconds: 250),
|
||||||
);
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index) {
|
||||||
|
setState(() => _currentIndex = index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle back press with double-tap to exit
|
||||||
|
void _handleBackPress() {
|
||||||
|
final trackState = ref.read(trackProvider);
|
||||||
|
|
||||||
|
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||||
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
if (isKeyboardVisible) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
||||||
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
|
ref.read(trackProvider.notifier).clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not on Home tab, go to Home tab first
|
||||||
|
if (_currentIndex != 0) {
|
||||||
|
_onNavTap(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If loading, ignore back press
|
||||||
|
if (trackState.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-tap to exit
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||||
|
SystemNavigator.pop();
|
||||||
|
} else {
|
||||||
|
_lastBackPress = now;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Press back again to exit'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final queueState = ref.watch(downloadQueueProvider);
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
|
final trackState = ref.watch(trackProvider);
|
||||||
|
|
||||||
|
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||||
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
|
||||||
|
// Determine if we can pop (for predictive back animation)
|
||||||
|
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||||
|
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
|
||||||
|
final canPop = _currentIndex == 0 &&
|
||||||
|
!trackState.hasSearchText &&
|
||||||
|
!trackState.hasContent &&
|
||||||
|
!trackState.isLoading &&
|
||||||
|
!isKeyboardVisible;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
appBar: AppBar(
|
canPop: canPop,
|
||||||
leading: Padding(
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
padding: const EdgeInsets.all(8.0),
|
if (didPop) {
|
||||||
child: ClipRRect(
|
// System handled the pop - this means predictive back completed
|
||||||
borderRadius: BorderRadius.circular(20),
|
// We need to handle double-tap to exit here
|
||||||
child: Image.asset(
|
return;
|
||||||
'assets/images/logo.png',
|
}
|
||||||
width: 40,
|
|
||||||
height: 40,
|
// Handle back press manually when canPop is false
|
||||||
),
|
_handleBackPress();
|
||||||
),
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: PageView(
|
||||||
|
controller: _pageController,
|
||||||
|
onPageChanged: _onPageChanged,
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
children: const [
|
||||||
|
HomeTab(),
|
||||||
|
QueueTab(),
|
||||||
|
SettingsTab(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
title: const Text('SpotiFLAC'),
|
bottomNavigationBar: NavigationBar(
|
||||||
),
|
selectedIndex: _currentIndex,
|
||||||
body: PageView(
|
onDestinationSelected: _onNavTap,
|
||||||
controller: _pageController,
|
animationDuration: const Duration(milliseconds: 200),
|
||||||
onPageChanged: _onPageChanged,
|
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||||
physics: const BouncingScrollPhysics(),
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||||
children: const [
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||||
HomeTab(),
|
destinations: [
|
||||||
QueueTab(),
|
const NavigationDestination(
|
||||||
SettingsTab(),
|
icon: Icon(Icons.home_outlined),
|
||||||
],
|
selectedIcon: Icon(Icons.home),
|
||||||
),
|
label: 'Home',
|
||||||
bottomNavigationBar: NavigationBar(
|
|
||||||
selectedIndex: _currentIndex,
|
|
||||||
onDestinationSelected: _onNavTap,
|
|
||||||
animationDuration: const Duration(milliseconds: 300),
|
|
||||||
destinations: [
|
|
||||||
const NavigationDestination(
|
|
||||||
icon: Icon(Icons.home_outlined),
|
|
||||||
selectedIcon: Icon(Icons.home),
|
|
||||||
label: 'Home',
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
|
||||||
icon: Badge(
|
|
||||||
isLabelVisible: queueState.queuedCount > 0,
|
|
||||||
label: Text('${queueState.queuedCount}'),
|
|
||||||
child: const Icon(Icons.download_outlined),
|
|
||||||
),
|
),
|
||||||
selectedIcon: Badge(
|
NavigationDestination(
|
||||||
isLabelVisible: queueState.queuedCount > 0,
|
icon: Badge(
|
||||||
label: Text('${queueState.queuedCount}'),
|
isLabelVisible: queueState > 0,
|
||||||
child: const Icon(Icons.download),
|
label: Text('$queueState'),
|
||||||
|
child: const Icon(Icons.history_outlined),
|
||||||
|
),
|
||||||
|
selectedIcon: Badge(
|
||||||
|
isLabelVisible: queueState > 0,
|
||||||
|
label: Text('$queueState'),
|
||||||
|
child: const Icon(Icons.history),
|
||||||
|
),
|
||||||
|
label: 'History',
|
||||||
),
|
),
|
||||||
label: 'Downloads',
|
const NavigationDestination(
|
||||||
),
|
icon: Icon(Icons.settings_outlined),
|
||||||
const NavigationDestination(
|
selectedIcon: Icon(Icons.settings),
|
||||||
icon: Icon(Icons.settings_outlined),
|
label: 'Settings',
|
||||||
selectedIcon: Icon(Icons.settings),
|
),
|
||||||
label: 'Settings',
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
|
||||||
|
/// Playlist detail screen with Material Expressive 3 design
|
||||||
|
class PlaylistScreen extends ConsumerWidget {
|
||||||
|
final String playlistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<Track> tracks;
|
||||||
|
|
||||||
|
const PlaylistScreen({
|
||||||
|
super.key,
|
||||||
|
required this.playlistName,
|
||||||
|
this.coverUrl,
|
||||||
|
required this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, ref, colorScheme),
|
||||||
|
_buildTrackListHeader(context, colorScheme),
|
||||||
|
_buildTrackList(context, ref, colorScheme),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (coverUrl != null)
|
||||||
|
CachedNetworkImage(imageUrl: 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: coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, 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, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
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(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _downloadAll(context, ref),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text('Download All (${tracks.length})'),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _PlaylistTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, ref, track),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadAll(BuildContext context, WidgetRef ref) {
|
||||||
|
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);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
|
final String trackName;
|
||||||
|
final String? artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isOverflowing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||||
|
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||||
|
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||||
|
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _isOverflowing != titleOverflows) {
|
||||||
|
setState(() => _isOverflowing = titleOverflows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: _expanded ? 10 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.artistName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.artistName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: _expanded ? 3 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isOverflowing || _expanded)
|
||||||
|
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
class _PlaylistTrackItem extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _PlaylistTrackItem({required this.track, required this.onDownload});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track
|
||||||
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if track is in history (already downloaded before)
|
||||||
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
|
return state.isDownloaded(track.id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final isQueued = queueItem != null;
|
||||||
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
|
// Show as downloaded if in queue completed OR in history
|
||||||
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||||
|
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, 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: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isDownloading,
|
||||||
|
required bool isFinalizing,
|
||||||
|
required bool showAsDownloaded,
|
||||||
|
required bool isInHistory,
|
||||||
|
required double progress,
|
||||||
|
}) {
|
||||||
|
const double size = 44.0;
|
||||||
|
const double iconSize = 20.0;
|
||||||
|
|
||||||
|
if (showAsDownloaded) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isDownloading) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isQueued) {
|
||||||
|
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||||
|
} else {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onDownload,
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,18 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
case DownloadStatus.finalizing:
|
||||||
|
return SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
case DownloadStatus.completed:
|
case DownloadStatus.completed:
|
||||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||||
case DownloadStatus.failed:
|
case DownloadStatus.failed:
|
||||||
|
|||||||
@@ -1,250 +1,765 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
|
||||||
class QueueTab extends ConsumerWidget {
|
class QueueTab extends ConsumerStatefulWidget {
|
||||||
const QueueTab({super.key});
|
const QueueTab({super.key});
|
||||||
|
@override
|
||||||
|
ConsumerState<QueueTab> createState() => _QueueTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
|
final Map<String, bool> _fileExistsCache = {};
|
||||||
|
final Set<String> _pendingChecks = {}; // Track pending async checks
|
||||||
|
static const int _maxCacheSize = 500; // Limit cache size to prevent memory leak
|
||||||
|
|
||||||
|
/// Check if file exists - returns true optimistically while checking
|
||||||
|
/// This prevents the "red flash" on app start
|
||||||
|
bool _checkFileExists(String? filePath) {
|
||||||
|
if (filePath == null) return false;
|
||||||
|
|
||||||
|
// If already cached, return cached value
|
||||||
|
if (_fileExistsCache.containsKey(filePath)) {
|
||||||
|
return _fileExistsCache[filePath]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If check is pending, return true optimistically (assume file exists)
|
||||||
|
if (_pendingChecks.contains(filePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit cache size - remove oldest entry if full
|
||||||
|
if (_fileExistsCache.length >= _maxCacheSize) {
|
||||||
|
_fileExistsCache.remove(_fileExistsCache.keys.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as pending and start async check
|
||||||
|
_pendingChecks.add(filePath);
|
||||||
|
Future.microtask(() async {
|
||||||
|
final exists = await File(filePath).exists();
|
||||||
|
_pendingChecks.remove(filePath);
|
||||||
|
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||||
|
setState(() => _fileExistsCache[filePath] = exists);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return true optimistically while checking
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(DownloadItem item) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider).items.firstWhere(
|
||||||
|
(h) => h.filePath == item.filePath,
|
||||||
|
orElse: () => DownloadHistoryItem(
|
||||||
|
id: item.id,
|
||||||
|
trackName: item.track.name,
|
||||||
|
artistName: item.track.artistName,
|
||||||
|
albumName: item.track.albumName,
|
||||||
|
coverUrl: item.track.coverUrl,
|
||||||
|
filePath: item.filePath ?? '',
|
||||||
|
downloadedAt: DateTime.now(),
|
||||||
|
service: item.service,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.push(context, PageRouteBuilder(
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: historyItem),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final queueState = ref.watch(downloadQueueProvider);
|
// Use select() to only rebuild when specific fields change
|
||||||
|
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||||
|
final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing));
|
||||||
|
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||||
|
final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
|
final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount));
|
||||||
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
return CustomScrollView(
|
||||||
children: [
|
slivers: [
|
||||||
// Header with actions
|
// Collapsing App Bar - Simplified for performance
|
||||||
if (queueState.items.isNotEmpty)
|
SliverAppBar(
|
||||||
Padding(
|
expandedHeight: 130,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
collapsedHeight: kToolbarHeight,
|
||||||
child: Row(
|
floating: false,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
pinned: true,
|
||||||
children: [
|
backgroundColor: colorScheme.surface,
|
||||||
Text(
|
surfaceTintColor: Colors.transparent,
|
||||||
'${queueState.items.length} items',
|
automaticallyImplyLeading: false,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
color: colorScheme.onSurfaceVariant,
|
expandedTitleScale: 1.3,
|
||||||
),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
Row(
|
'History',
|
||||||
children: [
|
style: TextStyle(
|
||||||
TextButton.icon(
|
fontSize: 28,
|
||||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
fontWeight: FontWeight.bold,
|
||||||
icon: const Icon(Icons.done_all, size: 18),
|
color: colorScheme.onSurface,
|
||||||
label: const Text('Clear done'),
|
),
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _showClearAllDialog(context, ref),
|
|
||||||
icon: Icon(Icons.clear_all, size: 18, color: colorScheme.error),
|
|
||||||
label: Text('Clear all', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Queue list
|
|
||||||
Expanded(
|
|
||||||
child: queueState.items.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: queueState.items.length,
|
|
||||||
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Pause/Resume controls - only show when multiple items or paused
|
||||||
|
if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused))
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Status icon
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isPaused
|
||||||
|
? colorScheme.errorContainer
|
||||||
|
: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isPaused ? Icons.pause : Icons.downloading,
|
||||||
|
color: isPaused
|
||||||
|
? colorScheme.onErrorContainer
|
||||||
|
: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Status text - simplified
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isPaused
|
||||||
|
? 'Paused'
|
||||||
|
: '$completedCount/${queueItems.length}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Pause/Resume button
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
||||||
|
child: Text(isPaused ? 'Resume' : 'Pause'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Queue header
|
||||||
|
if (queueItems.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Text('Downloading (${queueItems.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Queue list with keys for efficient updates
|
||||||
|
if (queueItems.isNotEmpty)
|
||||||
|
SliverList(delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final item = queueItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildQueueItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: queueItems.length,
|
||||||
|
)),
|
||||||
|
|
||||||
|
// History section header - show count only
|
||||||
|
if (historyItems.isNotEmpty && queueItems.isEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// History section header when queue has items (show "Downloaded" label)
|
||||||
|
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text('Downloaded',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// History - Grid or List based on setting (with keys)
|
||||||
|
if (historyItems.isNotEmpty)
|
||||||
|
historyViewMode == 'grid'
|
||||||
|
? SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final item = historyItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildHistoryGridItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: historyItems.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SliverList(delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final item = historyItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildHistoryItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: historyItems.length,
|
||||||
|
)),
|
||||||
|
|
||||||
|
// Empty state when both queue and history are empty
|
||||||
|
if (queueItems.isEmpty && historyItems.isEmpty)
|
||||||
|
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
||||||
|
else
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center(
|
||||||
return Center(
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
child: Column(
|
Icon(Icons.history, size: 64, color: colorScheme.onSurfaceVariant),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const SizedBox(height: 16),
|
||||||
children: [
|
Text('No download history', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
Icon(
|
const SizedBox(height: 8),
|
||||||
Icons.queue_music,
|
Text('Downloaded tracks will appear here', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
|
||||||
size: 64,
|
]),
|
||||||
color: colorScheme.onSurfaceVariant,
|
);
|
||||||
|
|
||||||
|
Widget _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||||
|
final isCompleted = item.status == DownloadStatus.completed;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Cover art with Hero for completed items
|
||||||
|
isCompleted
|
||||||
|
? Hero(
|
||||||
|
tag: 'cover_${item.id}',
|
||||||
|
child: _buildCoverArt(item, colorScheme),
|
||||||
|
)
|
||||||
|
: _buildCoverArt(item, colorScheme),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Track info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.track.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.status == DownloadStatus.downloading) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: item.progress > 0 ? item.progress : null,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${(item.progress * 100).toStringAsFixed(0)}%',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (item.status == DownloadStatus.failed) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
item.error ?? 'Download failed',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Action buttons based on status
|
||||||
|
_buildActionButtons(context, item, colorScheme),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
Text(
|
|
||||||
'No downloads in queue',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Add tracks from the Home tab',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
|
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
||||||
return ListTile(
|
return item.track.coverUrl != null
|
||||||
leading: item.track.coverUrl != null
|
? ClipRRect(
|
||||||
? ClipRRect(
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.track.coverUrl!,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 112,
|
||||||
|
memCacheHeight: 112,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.track.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
),
|
||||||
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
subtitle: Column(
|
);
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.track.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
if (item.status == DownloadStatus.downloading) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: item.progress > 0 ? item.progress : null,
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: _buildStatusIcon(context, item, colorScheme),
|
|
||||||
onTap: item.status == DownloadStatus.queued
|
|
||||||
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||||
switch (item.status) {
|
switch (item.status) {
|
||||||
case DownloadStatus.queued:
|
case DownloadStatus.queued:
|
||||||
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
|
// Queued: Show cancel button
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||||
|
icon: Icon(Icons.close, color: colorScheme.error),
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
case DownloadStatus.downloading:
|
case DownloadStatus.downloading:
|
||||||
return SizedBox(
|
// Downloading: Show stop button
|
||||||
width: 24,
|
return Row(
|
||||||
height: 24,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: CircularProgressIndicator(
|
children: [
|
||||||
value: item.progress,
|
IconButton(
|
||||||
strokeWidth: 2,
|
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||||
color: colorScheme.primary,
|
icon: Icon(Icons.stop, color: colorScheme.error),
|
||||||
),
|
tooltip: 'Stop',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case DownloadStatus.finalizing:
|
||||||
|
// Finalizing: Show spinner with edit icon (embedding metadata)
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
case DownloadStatus.completed:
|
case DownloadStatus.completed:
|
||||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
// Completed: Show play button and check icon
|
||||||
case DownloadStatus.failed:
|
final fileExists = _checkFileExists(item.filePath);
|
||||||
return IconButton(
|
return Row(
|
||||||
icon: Icon(Icons.error, color: colorScheme.error),
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () => _showErrorDialog(context, item, colorScheme),
|
children: [
|
||||||
tooltip: 'Tap to see error details',
|
if (fileExists)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _openFile(item.filePath!),
|
||||||
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
|
tooltip: 'Play',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case DownloadStatus.failed:
|
||||||
|
// Failed: Show retry and remove buttons
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
|
||||||
|
icon: Icon(Icons.refresh, color: colorScheme.primary),
|
||||||
|
tooltip: 'Retry',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
|
||||||
|
icon: Icon(Icons.close, color: colorScheme.error),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
case DownloadStatus.skipped:
|
case DownloadStatus.skipped:
|
||||||
return Icon(Icons.skip_next, color: colorScheme.primary);
|
// Skipped: Show retry and remove buttons
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
|
||||||
|
icon: Icon(Icons.refresh, color: colorScheme.primary),
|
||||||
|
tooltip: 'Retry',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
|
||||||
|
icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
||||||
showDialog(
|
Navigator.push(context, PageRouteBuilder(
|
||||||
context: context,
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
builder: (context) => AlertDialog(
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
title: Row(
|
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||||
children: [
|
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||||
Icon(Icons.error, color: colorScheme.error),
|
));
|
||||||
const SizedBox(width: 8),
|
}
|
||||||
const Text('Download Failed'),
|
|
||||||
],
|
Widget _buildHistoryGridItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||||
),
|
final fileExists = _checkFileExists(item.filePath);
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
return GestureDetector(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onTap: () => _navigateToHistoryMetadataScreen(item),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Cover art with play button overlay
|
||||||
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
AspectRatio(
|
||||||
Text('Artist: ${item.track.artistName}'),
|
aspectRatio: 1,
|
||||||
const SizedBox(height: 16),
|
child: ClipRRect(
|
||||||
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: item.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 200,
|
||||||
|
memCacheHeight: 200,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
),
|
||||||
item.error ?? 'Unknown error',
|
// Quality badge (top-left)
|
||||||
style: TextStyle(
|
if (item.quality != null && item.quality!.contains('bit'))
|
||||||
fontFamily: 'monospace',
|
Positioned(
|
||||||
fontSize: 12,
|
left: 4,
|
||||||
color: colorScheme.onErrorContainer,
|
top: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.tertiary
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.quality!.split('/').first, // Just show "24-bit" or "16-bit"
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.onTertiary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Play button overlay
|
||||||
|
if (fileExists)
|
||||||
|
Positioned(
|
||||||
|
right: 4,
|
||||||
|
bottom: 4,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _openFile(item.filePath),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.play_arrow, color: colorScheme.onPrimary, size: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Error indicator if file missing
|
||||||
|
if (!fileExists)
|
||||||
|
Positioned(
|
||||||
|
right: 4,
|
||||||
|
bottom: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.error_outline, color: colorScheme.error, size: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Track name
|
||||||
|
Text(
|
||||||
|
item.trackName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Artist name
|
||||||
|
Text(
|
||||||
|
item.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||||
|
final fileExists = _checkFileExists(item.filePath);
|
||||||
|
final date = item.downloadedAt;
|
||||||
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
final dateStr = '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _navigateToHistoryMetadataScreen(item),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Cover art
|
||||||
|
item.coverUrl != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 112,
|
||||||
|
memCacheHeight: 112,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Track info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.trackName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
dateStr,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Quality badge
|
||||||
|
if (item.quality != null && item.quality!.contains('bit')) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.tertiaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.quality!,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.onTertiaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (fileExists)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _openFile(item.filePath),
|
||||||
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
|
tooltip: 'Play',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear All'),
|
|
||||||
content: const Text('Are you sure you want to clear all downloads?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class AboutPage extends StatelessWidget {
|
||||||
|
const AboutPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
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(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: _AppHeaderCard(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Contributors section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Contributors'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_ContributorItem(
|
||||||
|
name: AppInfo.mobileAuthor,
|
||||||
|
description: 'Mobile version developer',
|
||||||
|
githubUsername: AppInfo.mobileAuthor,
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: AppInfo.originalAuthor,
|
||||||
|
description: 'Creator of the original SpotiFLAC',
|
||||||
|
githubUsername: AppInfo.originalAuthor,
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Links section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Links'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.phone_android,
|
||||||
|
title: 'Mobile source code',
|
||||||
|
subtitle: 'github.com/${AppInfo.githubRepo}',
|
||||||
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.computer,
|
||||||
|
title: 'PC source code',
|
||||||
|
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
||||||
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
title: 'Report an issue',
|
||||||
|
subtitle: 'Report any problems you encounter',
|
||||||
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.lightbulb_outline,
|
||||||
|
title: 'Feature request',
|
||||||
|
subtitle: 'Suggest new features for the app',
|
||||||
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// App info section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'App'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.info_outline,
|
||||||
|
title: 'Version',
|
||||||
|
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Copyright
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
AppInfo.copyright,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _launchUrl(String url) async {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
// Use inAppBrowserView for reliable URL opening with app chooser
|
||||||
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppHeaderCard extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final cardColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHighest;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// App name
|
||||||
|
Text(
|
||||||
|
AppInfo.appName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Version badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'v${AppInfo.version}',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Description
|
||||||
|
Text(
|
||||||
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContributorItem extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final String githubUsername;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const _ContributorItem({
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.githubUsername,
|
||||||
|
this.showDivider = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _launchGitHub(githubUsername),
|
||||||
|
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: [
|
||||||
|
// GitHub Avatar
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: 'https://github.com/$githubUsername.png',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Name and description
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// GitHub icon
|
||||||
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 76,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchGitHub(String username) async {
|
||||||
|
final uri = Uri.parse('https://github.com/$username');
|
||||||
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class AppearanceSettingsPage extends ConsumerWidget {
|
||||||
|
const AppearanceSettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final themeSettings = ref.watch(themeProvider);
|
||||||
|
final settings = ref.watch(settingsProvider);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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});
|
||||||
|
|
||||||
|
@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)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThemeModeChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback 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);
|
||||||
|
|
||||||
|
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)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
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)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||||
|
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')),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ViewModeChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback 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);
|
||||||
|
|
||||||
|
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)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
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)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class DownloadSettingsPage extends ConsumerWidget {
|
||||||
|
const DownloadSettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final settings = ref.watch(settingsProvider);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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'),
|
||||||
|
),
|
||||||
|
_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),
|
||||||
|
),
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||||
|
final controller = TextEditingController(text: current);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context, isScrollControlled: true,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
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')),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDirectory(WidgetRef ref) async {
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFolderOrganizationLabel(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'artist':
|
||||||
|
return 'By Artist';
|
||||||
|
case 'album':
|
||||||
|
return 'By Album';
|
||||||
|
case 'artist_album':
|
||||||
|
return 'By Artist & Album';
|
||||||
|
default:
|
||||||
|
return 'None';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
child: Text('Folder Organization', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text('Organize downloaded files into folders', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
_FolderOption(
|
||||||
|
title: 'None',
|
||||||
|
subtitle: 'All files in download folder',
|
||||||
|
example: 'SpotiFLAC/Track.flac',
|
||||||
|
isSelected: current == 'none',
|
||||||
|
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); },
|
||||||
|
),
|
||||||
|
_FolderOption(
|
||||||
|
title: 'By Artist',
|
||||||
|
subtitle: 'Separate folder for each artist',
|
||||||
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
|
isSelected: current == 'artist',
|
||||||
|
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); },
|
||||||
|
),
|
||||||
|
_FolderOption(
|
||||||
|
title: 'By Album',
|
||||||
|
subtitle: 'Separate folder for each album',
|
||||||
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
|
isSelected: current == 'album',
|
||||||
|
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); },
|
||||||
|
),
|
||||||
|
_FolderOption(
|
||||||
|
title: 'By Artist & Album',
|
||||||
|
subtitle: 'Nested folders for artist and album',
|
||||||
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
|
isSelected: current == 'artist_album',
|
||||||
|
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); },
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceSelector extends StatelessWidget {
|
||||||
|
final String currentService;
|
||||||
|
final ValueChanged<String> 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')),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback 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)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
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)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
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});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isSelected
|
||||||
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FolderOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
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});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(subtitle),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
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),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class OptionsSettingsPage extends ConsumerWidget {
|
||||||
|
const OptionsSettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final settings = ref.watch(settingsProvider);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
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(
|
||||||
|
'Options',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Download options section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.sync,
|
||||||
|
title: 'Auto Fallback',
|
||||||
|
subtitle: 'Try other services if download fails',
|
||||||
|
value: settings.autoFallback,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.lyrics,
|
||||||
|
title: 'Embed Lyrics',
|
||||||
|
subtitle: 'Embed synced lyrics into FLAC files',
|
||||||
|
value: settings.embedLyrics,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.image,
|
||||||
|
title: 'Max Quality Cover',
|
||||||
|
subtitle: 'Download highest resolution cover art',
|
||||||
|
value: settings.maxQualityCover,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Performance section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Performance')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_ConcurrentDownloadsItem(
|
||||||
|
currentValue: settings.concurrentDownloads,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// App section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.system_update,
|
||||||
|
title: 'Check for Updates',
|
||||||
|
subtitle: 'Notify when new version is available',
|
||||||
|
value: settings.checkForUpdates,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Spotify API section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Spotify API')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.key,
|
||||||
|
title: 'Custom Credentials',
|
||||||
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
|
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||||
|
: 'Not configured',
|
||||||
|
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
|
trailing: settings.spotifyClientId.isNotEmpty
|
||||||
|
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||||
|
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||||
|
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||||
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.toggle_on,
|
||||||
|
title: 'Use Custom Credentials',
|
||||||
|
subtitle: settings.useCustomSpotifyCredentials
|
||||||
|
? 'Using your credentials'
|
||||||
|
: 'Using default credentials',
|
||||||
|
value: settings.useCustomSpotifyCredentials,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Data section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.delete_forever,
|
||||||
|
title: 'Clear Download History',
|
||||||
|
subtitle: 'Remove all downloaded tracks from history',
|
||||||
|
onTap: () => _showClearHistoryDialog(context, ref, colorScheme),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showClearHistoryDialog(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear History'),
|
||||||
|
content: const Text('Are you sure you want to clear all download history? This cannot be undone.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('History cleared')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSpotifyCredentialsDialog(BuildContext context, WidgetRef ref, AppSettings settings) {
|
||||||
|
final clientIdController = TextEditingController(text: settings.spotifyClientId);
|
||||||
|
final clientSecretController = TextEditingController(text: settings.spotifyClientSecret);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (context) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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, 20, 24, 8),
|
||||||
|
child: Text('Spotify API Credentials', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'Use your own credentials to avoid rate limiting.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: TextField(
|
||||||
|
controller: clientIdController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client ID',
|
||||||
|
hintText: 'Enter Spotify Client ID',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerLow,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: TextField(
|
||||||
|
controller: clientSecretController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client Secret',
|
||||||
|
hintText: 'Enter Spotify Client Secret',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerLow,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(settingsProvider.notifier).clearSpotifyCredentials();
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Credentials cleared')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty) const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final clientId = clientIdController.text.trim();
|
||||||
|
final clientSecret = clientSecretController.text.trim();
|
||||||
|
|
||||||
|
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
|
||||||
|
ref.read(settingsProvider.notifier).setSpotifyCredentials(clientId, clientSecret);
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Credentials saved')),
|
||||||
|
);
|
||||||
|
} else if (clientId.isEmpty && clientSecret.isEmpty) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please fill both Client ID and Secret')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||||
|
final int currentValue;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
const _ConcurrentDownloadsItem({required this.currentValue, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('Concurrent Downloads', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
_ConcurrentChip(label: '1', isSelected: currentValue == 1, onTap: () => onChanged(1)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ConcurrentChip(label: '2', isSelected: currentValue == 2, onTap: () => onChanged(2)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ConcurrentChip(label: '3', isSelected: currentValue == 3, onTap: () => onChanged(3)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text('Parallel downloads may trigger rate limiting',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.error))),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConcurrentChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ConcurrentChip({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)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(child: Text(label, style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class SettingsTab extends ConsumerWidget {
|
||||||
|
const SettingsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// Collapsing App Bar
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 130,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// First group: Appearance & Download
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.palette_outlined,
|
||||||
|
title: 'Appearance',
|
||||||
|
subtitle: 'Theme, colors, display',
|
||||||
|
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.download_outlined,
|
||||||
|
title: 'Download',
|
||||||
|
subtitle: 'Service, quality, filename format',
|
||||||
|
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.tune_outlined,
|
||||||
|
title: 'Options',
|
||||||
|
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||||
|
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Second group: About
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.info_outline,
|
||||||
|
title: 'About',
|
||||||
|
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
||||||
|
onTap: () => _navigateTo(context, const AboutPage()),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Fill remaining space
|
||||||
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateTo(BuildContext context, Widget page) {
|
||||||
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
|
||||||
@@ -133,6 +134,15 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
: '${settings.concurrentDownloads} parallel downloads'),
|
: '${settings.concurrentDownloads} parallel downloads'),
|
||||||
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Check for Updates
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: Icon(Icons.system_update, color: colorScheme.primary),
|
||||||
|
title: const Text('Check for Updates'),
|
||||||
|
subtitle: const Text('Notify when new version is available'),
|
||||||
|
value: settings.checkForUpdates,
|
||||||
|
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
|
||||||
|
),
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
@@ -141,22 +151,22 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||||
title: const Text('SpotiFLAC Mobile'),
|
title: Text('${AppInfo.appName} Mobile'),
|
||||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Mobile version maintained by zarzet\nOriginal project by afkarxyz',
|
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -169,7 +179,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
subtitle: const Text('SpotiFLAC v1.1.1'),
|
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||||
onTap: () => _showAboutDialog(context),
|
onTap: () => _showAboutDialog(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -184,23 +194,23 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text('SpotiFLAC'),
|
Text(AppInfo.appName),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildAboutRow('Version', '1.1.1', colorScheme),
|
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Mobile', 'zarzet', colorScheme),
|
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Original', 'afkarxyz', colorScheme),
|
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'© 2026 SpotiFLAC',
|
AppInfo.copyright,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -382,7 +392,19 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
title: const Text('Select Quality'),
|
title: const Text('Select Quality'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
|
||||||
@@ -140,6 +141,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
: '${settings.concurrentDownloads} parallel downloads'),
|
: '${settings.concurrentDownloads} parallel downloads'),
|
||||||
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Check for Updates
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: Icon(Icons.system_update, color: colorScheme.primary),
|
||||||
|
title: const Text('Check for Updates'),
|
||||||
|
subtitle: const Text('Notify when new version is available'),
|
||||||
|
value: settings.checkForUpdates,
|
||||||
|
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
|
||||||
|
),
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
@@ -148,22 +158,22 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||||
title: const Text('SpotiFLAC Mobile'),
|
title: Text('${AppInfo.appName} Mobile'),
|
||||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
title: Text('Original ${AppInfo.appName} (Desktop)'),
|
||||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Mobile version maintained by zarzet\nOriginal project by afkarxyz',
|
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -176,7 +186,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
subtitle: const Text('SpotiFLAC v1.1.1'),
|
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||||
onTap: () => _showAboutDialog(context),
|
onTap: () => _showAboutDialog(context),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -193,23 +203,23 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text('SpotiFLAC'),
|
Text(AppInfo.appName),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildAboutRow('Version', '1.1.1', colorScheme),
|
_buildAboutRow('Version', AppInfo.version, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Mobile', 'zarzet', colorScheme),
|
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildAboutRow('Original', 'afkarxyz', colorScheme),
|
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'© 2026 SpotiFLAC',
|
AppInfo.copyright,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -268,8 +278,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
|
|
||||||
String _getQualityName(String quality) {
|
String _getQualityName(String quality) {
|
||||||
switch (quality) {
|
switch (quality) {
|
||||||
case 'LOSSLESS': return 'FLAC (Lossless)';
|
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
|
||||||
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
|
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
|
||||||
|
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
|
||||||
default: return quality;
|
default: return quality;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,9 +389,22 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
title: const Text('Select Quality'),
|
title: const Text('Select Quality'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
|
||||||
|
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@@ -17,11 +18,15 @@ class SetupScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _SetupScreenState extends ConsumerState<SetupScreen> {
|
class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||||
int _currentStep = 0;
|
int _currentStep = 0;
|
||||||
bool _permissionGranted = false;
|
bool _storagePermissionGranted = false;
|
||||||
|
bool _notificationPermissionGranted = false;
|
||||||
String? _selectedDirectory;
|
String? _selectedDirectory;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
|
|
||||||
|
// Total steps: Storage -> Notification (Android 13+) -> Folder
|
||||||
|
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -35,63 +40,101 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_androidSdkVersion = androidInfo.version.sdkInt;
|
_androidSdkVersion = androidInfo.version.sdkInt;
|
||||||
debugPrint('Android SDK Version: $_androidSdkVersion');
|
debugPrint('Android SDK Version: $_androidSdkVersion');
|
||||||
}
|
}
|
||||||
await _checkInitialPermission();
|
await _checkInitialPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkInitialPermission() async {
|
Future<void> _checkInitialPermissions() async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS doesn't need storage permission - app uses its own Documents directory
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _permissionGranted = true);
|
setState(() {
|
||||||
|
_storagePermissionGranted = true;
|
||||||
|
_notificationPermissionGranted = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
PermissionStatus status;
|
// Check storage permission
|
||||||
|
PermissionStatus storageStatus;
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
status = await Permission.audio.status;
|
storageStatus = await Permission.audio.status;
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
status = await Permission.manageExternalStorage.status;
|
storageStatus = await Permission.manageExternalStorage.status;
|
||||||
} else {
|
} else {
|
||||||
status = await Permission.storage.status;
|
storageStatus = await Permission.storage.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.isGranted && mounted) {
|
// Check notification permission (Android 13+)
|
||||||
setState(() => _permissionGranted = true);
|
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||||
|
if (_androidSdkVersion >= 33) {
|
||||||
|
notificationStatus = await Permission.notification.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_storagePermissionGranted = storageStatus.isGranted;
|
||||||
|
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _requestPermission() async {
|
Future<void> _requestStoragePermission() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS doesn't need storage permission - app uses its own Documents directory
|
setState(() => _storagePermissionGranted = true);
|
||||||
setState(() => _permissionGranted = true);
|
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
PermissionStatus status;
|
PermissionStatus status;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
|
// Android 13+: Use audio permission
|
||||||
status = await Permission.audio.request();
|
status = await Permission.audio.request();
|
||||||
if (!status.isGranted) {
|
|
||||||
await Permission.notification.request();
|
|
||||||
}
|
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
status = await Permission.manageExternalStorage.request();
|
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE
|
||||||
|
// This opens system settings, not a dialog
|
||||||
|
status = await Permission.manageExternalStorage.status;
|
||||||
|
if (!status.isGranted) {
|
||||||
|
// Show explanation dialog first
|
||||||
|
if (mounted) {
|
||||||
|
final shouldOpen = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Storage Access Required'),
|
||||||
|
content: const Text(
|
||||||
|
'Android 11+ requires "All files access" permission to save music files.\n\n'
|
||||||
|
'Please enable "Allow access to manage all files" in the next screen.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Open Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldOpen == true) {
|
||||||
|
status = await Permission.manageExternalStorage.request();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Android 10 and below: Use legacy storage permission
|
||||||
status = await Permission.storage.request();
|
status = await Permission.storage.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.isGranted) {
|
if (status.isGranted) {
|
||||||
setState(() => _permissionGranted = true);
|
setState(() => _storagePermissionGranted = true);
|
||||||
} else if (status.isPermanentlyDenied) {
|
} else if (status.isPermanentlyDenied) {
|
||||||
_showPermissionDeniedDialog();
|
_showPermissionDeniedDialog('Storage');
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
|
||||||
content: Text('Permission denied. Please grant permission to continue.'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,22 +142,46 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Permission error: $e');
|
debugPrint('Permission error: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPermissionDeniedDialog() {
|
Future<void> _requestNotificationPermission() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_androidSdkVersion >= 33) {
|
||||||
|
final status = await Permission.notification.request();
|
||||||
|
if (status.isGranted) {
|
||||||
|
setState(() => _notificationPermissionGranted = true);
|
||||||
|
} else if (status.isPermanentlyDenied) {
|
||||||
|
_showPermissionDeniedDialog('Notification');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Notification permission not needed for older Android
|
||||||
|
setState(() => _notificationPermissionGranted = true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Notification permission error: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _skipNotificationPermission() {
|
||||||
|
setState(() => _notificationPermissionGranted = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPermissionDeniedDialog(String permissionType) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Permission Required'),
|
title: Text('$permissionType Permission Required'),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'Storage permission is required to save downloaded music files. '
|
'$permissionType permission is required for the best experience. '
|
||||||
'Please grant permission in app settings.',
|
'Please grant permission in app settings.',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -151,18 +218,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Use Default Folder?'),
|
title: const Text('Use Default Folder?'),
|
||||||
content: Text(
|
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
||||||
'No folder selected. Would you like to use the default Music folder?\n\n$defaultDir',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||||
onPressed: () => Navigator.pop(context, false),
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: const Text('Use Default'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -179,7 +238,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Future<String> _getDefaultDirectory() async {
|
Future<String> _getDefaultDirectory() async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS: Use Documents directory (accessible via Files app)
|
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
final musicDir = Directory('${appDir.path}/SpotiFLAC');
|
final musicDir = Directory('${appDir.path}/SpotiFLAC');
|
||||||
try {
|
try {
|
||||||
@@ -225,9 +283,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
@@ -244,9 +300,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minHeight: MediaQuery.of(context).size.height -
|
minHeight: math.max(0, MediaQuery.of(context).size.height -
|
||||||
MediaQuery.of(context).padding.top -
|
MediaQuery.of(context).padding.top -
|
||||||
MediaQuery.of(context).padding.bottom - 48,
|
MediaQuery.of(context).padding.bottom - 48),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -257,27 +313,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Image.asset(
|
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
||||||
'assets/images/logo.png',
|
|
||||||
width: 96,
|
|
||||||
height: 96,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text('SpotiFLAC',
|
||||||
'SpotiFLAC',
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text('Download Spotify tracks in FLAC',
|
||||||
'Download Spotify tracks in FLAC',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant)),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -287,9 +332,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildStepIndicator(colorScheme),
|
_buildStepIndicator(colorScheme),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_currentStep == 0
|
_buildCurrentStepContent(colorScheme),
|
||||||
? _buildPermissionStep(colorScheme)
|
|
||||||
: _buildDirectoryStep(colorScheme),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -310,27 +353,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||||
|
final steps = _androidSdkVersion >= 33
|
||||||
|
? ['Storage', 'Notification', 'Folder']
|
||||||
|
: ['Permission', 'Folder'];
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildStepDot(0, 'Permission', colorScheme),
|
for (int i = 0; i < steps.length; i++) ...[
|
||||||
Padding(
|
if (i > 0)
|
||||||
padding: const EdgeInsets.only(bottom: 20), // Offset for label height
|
Padding(
|
||||||
child: Container(
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
width: 40,
|
child: Container(
|
||||||
height: 2,
|
width: 32,
|
||||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
height: 2,
|
||||||
),
|
color: _currentStep >= i ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
_buildStepDot(1, 'Folder', colorScheme),
|
),
|
||||||
|
_buildStepDot(i, steps[i], colorScheme),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
||||||
final isActive = _currentStep >= step;
|
final isActive = _currentStep >= step;
|
||||||
final isCompleted = (step == 0 && _permissionGranted) ||
|
final isCompleted = _isStepCompleted(step);
|
||||||
(step == 1 && _selectedDirectory != null);
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -341,86 +389,143 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: isCompleted
|
color: isCompleted
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: isActive
|
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
? colorScheme.primaryContainer
|
|
||||||
: colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: isCompleted
|
child: isCompleted
|
||||||
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
|
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
|
||||||
: Text(
|
: Text('${step + 1}',
|
||||||
'${step + 1}',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold)),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(label,
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPermissionStep(ColorScheme colorScheme) {
|
bool _isStepCompleted(int step) {
|
||||||
|
if (_androidSdkVersion >= 33) {
|
||||||
|
// 3 steps: Storage, Notification, Folder
|
||||||
|
switch (step) {
|
||||||
|
case 0: return _storagePermissionGranted;
|
||||||
|
case 1: return _notificationPermissionGranted;
|
||||||
|
case 2: return _selectedDirectory != null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2 steps: Permission, Folder
|
||||||
|
switch (step) {
|
||||||
|
case 0: return _storagePermissionGranted;
|
||||||
|
case 1: return _selectedDirectory != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCurrentStepContent(ColorScheme colorScheme) {
|
||||||
|
if (_androidSdkVersion >= 33) {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
|
case 1: return _buildNotificationPermissionStep(colorScheme);
|
||||||
|
case 2: return _buildDirectoryStep(colorScheme);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
|
case 1: return _buildDirectoryStep(colorScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStoragePermissionStep(ColorScheme colorScheme) {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_permissionGranted ? Icons.check_circle : Icons.folder_open,
|
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
|
||||||
size: 56,
|
size: 56,
|
||||||
color: _permissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_permissionGranted
|
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
||||||
? 'Storage Permission Granted!'
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
: 'Storage Permission Required',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
_permissionGranted
|
_storagePermissionGranted
|
||||||
? 'You can now select where to save your music files.'
|
? 'You can now proceed to the next step.'
|
||||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (!_permissionGranted)
|
if (!_storagePermissionGranted)
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _requestPermission,
|
onPressed: _isLoading ? null : _requestStoragePermission,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(
|
? SizedBox(width: 20, height: 20,
|
||||||
width: 20,
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.security),
|
: const Icon(Icons.security),
|
||||||
label: const Text('Grant Permission'),
|
label: const Text('Grant Permission'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationPermissionStep(ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
|
||||||
|
size: 56,
|
||||||
|
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.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,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
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),
|
||||||
|
label: const Text('Enable Notifications'),
|
||||||
|
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _skipNotificationPermission,
|
||||||
|
child: const Text('Skip for now'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -433,12 +538,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_selectedDirectory != null
|
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
||||||
? 'Download Folder Selected!'
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
: 'Choose Download Folder',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -455,46 +556,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Icon(Icons.folder, color: colorScheme.primary, size: 20),
|
Icon(Icons.folder, color: colorScheme.primary, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(_selectedDirectory!,
|
||||||
_selectedDirectory!,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Text(
|
Text('Select a folder where your downloaded music will be saved.',
|
||||||
'Select a folder where your downloaded music will be saved.',
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
textAlign: TextAlign.center),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _selectDirectory,
|
onPressed: _isLoading ? null : _selectDirectory,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(
|
? SizedBox(width: 20, height: 20,
|
||||||
width: 20,
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
|
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
|
||||||
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
||||||
|
final isLastStep = _currentStep == _totalSteps - 1;
|
||||||
|
final canProceed = _isStepCompleted(_currentStep);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -509,41 +599,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
const SizedBox(width: 100),
|
const SizedBox(width: 100),
|
||||||
|
|
||||||
// Next/Finish button
|
// Next/Finish button
|
||||||
if (_currentStep == 0)
|
if (!isLastStep)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _permissionGranted
|
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||||
? () => setState(() => _currentStep++)
|
|
||||||
: null,
|
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
|
||||||
Text('Next'),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Icon(Icons.arrow_forward, size: 18),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _selectedDirectory != null && !_isLoading
|
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
|
||||||
? _completeSetup
|
|
||||||
: null,
|
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? SizedBox(
|
? SizedBox(width: 20, height: 20,
|
||||||
width: 20,
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Row(
|
: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
|
||||||
Text('Get Started'),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Icon(Icons.check, size: 18),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('ApkDownloader');
|
||||||
|
|
||||||
|
typedef ProgressCallback = void Function(int received, int total);
|
||||||
|
|
||||||
|
class ApkDownloader {
|
||||||
|
static Future<String?> downloadApk({
|
||||||
|
required String url,
|
||||||
|
required String version,
|
||||||
|
ProgressCallback? onProgress,
|
||||||
|
}) async {
|
||||||
|
// Validate URL for security
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || uri.scheme != 'https') {
|
||||||
|
_log.e('Refusing to download from invalid or non-HTTPS URL');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = http.Client();
|
||||||
|
IOSink? sink;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final request = http.Request('GET', uri);
|
||||||
|
final response = await client.send(request);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
_log.e('Failed to download: ${response.statusCode}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final contentLength = response.contentLength ?? 0;
|
||||||
|
|
||||||
|
// Get download directory
|
||||||
|
final dir = await getExternalStorageDirectory();
|
||||||
|
if (dir == null) {
|
||||||
|
_log.e('Could not get storage directory');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final filePath = '${dir.path}/SpotiFLAC-$version.apk';
|
||||||
|
final file = File(filePath);
|
||||||
|
|
||||||
|
// Delete if exists
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
sink = file.openWrite();
|
||||||
|
int received = 0;
|
||||||
|
|
||||||
|
await for (final chunk in response.stream) {
|
||||||
|
sink.add(chunk);
|
||||||
|
received += chunk.length;
|
||||||
|
onProgress?.call(received, contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sink.flush();
|
||||||
|
_log.i('Downloaded to: $filePath');
|
||||||
|
return filePath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Error: $e');
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await sink?.close();
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> installApk(String filePath) async {
|
||||||
|
try {
|
||||||
|
final result = await OpenFilex.open(filePath);
|
||||||
|
_log.i('Open result: ${result.type} - ${result.message}');
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Install error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.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 audio conversion and remuxing
|
/// FFmpeg service for audio conversion and remuxing
|
||||||
class FFmpegService {
|
class FFmpegService {
|
||||||
@@ -27,7 +30,7 @@ class FFmpegService {
|
|||||||
// Log error for debugging
|
// Log error for debugging
|
||||||
final logs = await session.getLogs();
|
final logs = await session.getLogs();
|
||||||
for (final log in logs) {
|
for (final log in logs) {
|
||||||
print('[FFmpeg] ${log.getMessage()}');
|
_log.d(log.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
static final NotificationService _instance = NotificationService._internal();
|
||||||
|
factory NotificationService() => _instance;
|
||||||
|
NotificationService._internal();
|
||||||
|
|
||||||
|
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
static const int downloadProgressId = 1;
|
||||||
|
static const int updateDownloadId = 2;
|
||||||
|
static const String channelId = 'download_progress';
|
||||||
|
static const String channelName = 'Download Progress';
|
||||||
|
static const String channelDescription = 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
const iosSettings = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: true,
|
||||||
|
requestBadgePermission: true,
|
||||||
|
requestSoundPermission: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initSettings = InitializationSettings(
|
||||||
|
android: androidSettings,
|
||||||
|
iOS: iosSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.initialize(initSettings);
|
||||||
|
|
||||||
|
// Create notification channel for Android
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.createNotificationChannel(
|
||||||
|
const AndroidNotificationChannel(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
description: channelDescription,
|
||||||
|
importance: Importance.low,
|
||||||
|
showBadge: false,
|
||||||
|
playSound: false,
|
||||||
|
enableVibration: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showDownloadProgress({
|
||||||
|
required String trackName,
|
||||||
|
required String artistName,
|
||||||
|
required int progress,
|
||||||
|
required int total,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.low,
|
||||||
|
priority: Priority.low,
|
||||||
|
showProgress: true,
|
||||||
|
maxProgress: 100,
|
||||||
|
progress: percentage,
|
||||||
|
ongoing: true,
|
||||||
|
autoCancel: false,
|
||||||
|
playSound: false,
|
||||||
|
enableVibration: false,
|
||||||
|
onlyAlertOnce: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
downloadProgressId,
|
||||||
|
'Downloading $trackName',
|
||||||
|
'$artistName • $percentage%',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showDownloadFinalizing({
|
||||||
|
required String trackName,
|
||||||
|
required String artistName,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.low,
|
||||||
|
priority: Priority.low,
|
||||||
|
showProgress: true,
|
||||||
|
maxProgress: 100,
|
||||||
|
progress: 100,
|
||||||
|
indeterminate: false,
|
||||||
|
ongoing: true,
|
||||||
|
autoCancel: false,
|
||||||
|
playSound: false,
|
||||||
|
enableVibration: false,
|
||||||
|
onlyAlertOnce: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
downloadProgressId,
|
||||||
|
'Finalizing $trackName',
|
||||||
|
'$artistName • Embedding metadata...',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showDownloadComplete({
|
||||||
|
required String trackName,
|
||||||
|
required String artistName,
|
||||||
|
int? completedCount,
|
||||||
|
int? totalCount,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final title = completedCount != null && totalCount != null
|
||||||
|
? 'Download Complete ($completedCount/$totalCount)'
|
||||||
|
: 'Download Complete';
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
autoCancel: true,
|
||||||
|
playSound: false,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
downloadProgressId,
|
||||||
|
title,
|
||||||
|
'$trackName - $artistName',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showQueueComplete({
|
||||||
|
required int completedCount,
|
||||||
|
required int failedCount,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final title = failedCount > 0
|
||||||
|
? 'Downloads Finished ($completedCount done, $failedCount failed)'
|
||||||
|
: 'All Downloads Complete';
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
autoCancel: true,
|
||||||
|
playSound: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
downloadProgressId,
|
||||||
|
title,
|
||||||
|
'$completedCount tracks downloaded successfully',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelDownloadNotification() async {
|
||||||
|
await _notifications.cancel(downloadProgressId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update APK download notifications
|
||||||
|
Future<void> showUpdateDownloadProgress({
|
||||||
|
required String version,
|
||||||
|
required int received,
|
||||||
|
required int total,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
|
||||||
|
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.low,
|
||||||
|
priority: Priority.low,
|
||||||
|
showProgress: true,
|
||||||
|
maxProgress: 100,
|
||||||
|
progress: percentage,
|
||||||
|
ongoing: true,
|
||||||
|
autoCancel: false,
|
||||||
|
playSound: false,
|
||||||
|
enableVibration: false,
|
||||||
|
onlyAlertOnce: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
updateDownloadId,
|
||||||
|
'Downloading SpotiFLAC v$version',
|
||||||
|
'$receivedMB / $totalMB MB • $percentage%',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showUpdateDownloadComplete({required String version}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
autoCancel: true,
|
||||||
|
playSound: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
updateDownloadId,
|
||||||
|
'Update Ready',
|
||||||
|
'SpotiFLAC v$version downloaded. Tap to install.',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showUpdateDownloadFailed() async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
autoCancel: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
updateDownloadId,
|
||||||
|
'Update Failed',
|
||||||
|
'Could not download update. Try again later.',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelUpdateNotification() async {
|
||||||
|
await _notifications.cancel(updateDownloadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,16 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search Spotify for tracks and artists
|
||||||
|
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||||
|
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||||
|
'query': query,
|
||||||
|
'track_limit': trackLimit,
|
||||||
|
'artist_limit': artistLimit,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check track availability on streaming services
|
/// Check track availability on streaming services
|
||||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||||
final result = await _channel.invokeMethod('checkAvailability', {
|
final result = await _channel.invokeMethod('checkAvailability', {
|
||||||
@@ -47,12 +57,15 @@ class PlatformBridge {
|
|||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
required String outputDir,
|
required String outputDir,
|
||||||
required String filenameFormat,
|
required String filenameFormat,
|
||||||
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
|
String? itemId,
|
||||||
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -65,12 +78,15 @@ class PlatformBridge {
|
|||||||
'cover_url': coverUrl,
|
'cover_url': coverUrl,
|
||||||
'output_dir': outputDir,
|
'output_dir': outputDir,
|
||||||
'filename_format': filenameFormat,
|
'filename_format': filenameFormat,
|
||||||
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
'disc_number': discNumber,
|
'disc_number': discNumber,
|
||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
'release_date': releaseDate ?? '',
|
'release_date': releaseDate ?? '',
|
||||||
|
'item_id': itemId ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||||
@@ -88,6 +104,7 @@ class PlatformBridge {
|
|||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
required String outputDir,
|
required String outputDir,
|
||||||
required String filenameFormat,
|
required String filenameFormat,
|
||||||
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
@@ -95,6 +112,8 @@ class PlatformBridge {
|
|||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String preferredService = 'tidal',
|
String preferredService = 'tidal',
|
||||||
|
String? itemId,
|
||||||
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -107,24 +126,48 @@ class PlatformBridge {
|
|||||||
'cover_url': coverUrl,
|
'cover_url': coverUrl,
|
||||||
'output_dir': outputDir,
|
'output_dir': outputDir,
|
||||||
'filename_format': filenameFormat,
|
'filename_format': filenameFormat,
|
||||||
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
'disc_number': discNumber,
|
'disc_number': discNumber,
|
||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
'release_date': releaseDate ?? '',
|
'release_date': releaseDate ?? '',
|
||||||
|
'item_id': itemId ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get download progress
|
/// Get download progress (legacy single download)
|
||||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get progress for all active downloads (concurrent mode)
|
||||||
|
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
||||||
|
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize progress tracking for a download item
|
||||||
|
static Future<void> initItemProgress(String itemId) async {
|
||||||
|
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish progress tracking for a download item
|
||||||
|
static Future<void> finishItemProgress(String itemId) async {
|
||||||
|
await _channel.invokeMethod('finishItemProgress', {'item_id': itemId});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear progress tracking for a download item
|
||||||
|
static Future<void> clearItemProgress(String itemId) async {
|
||||||
|
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
|
||||||
|
}
|
||||||
|
|
||||||
/// Set download directory
|
/// Set download directory
|
||||||
static Future<void> setDownloadDirectory(String path) async {
|
static Future<void> setDownloadDirectory(String path) async {
|
||||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||||
@@ -171,15 +214,18 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get lyrics in LRC format
|
/// Get lyrics in LRC format
|
||||||
|
/// First tries to extract from embedded file, then falls back to internet
|
||||||
static Future<String> getLyricsLRC(
|
static Future<String> getLyricsLRC(
|
||||||
String spotifyId,
|
String spotifyId,
|
||||||
String trackName,
|
String trackName,
|
||||||
String artistName,
|
String artistName, {
|
||||||
) async {
|
String? filePath,
|
||||||
|
}) async {
|
||||||
final result = await _channel.invokeMethod('getLyricsLRC', {
|
final result = await _channel.invokeMethod('getLyricsLRC', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'track_name': trackName,
|
'track_name': trackName,
|
||||||
'artist_name': artistName,
|
'artist_name': artistName,
|
||||||
|
'file_path': filePath ?? '',
|
||||||
});
|
});
|
||||||
return result as String;
|
return result as String;
|
||||||
}
|
}
|
||||||
@@ -201,4 +247,54 @@ class PlatformBridge {
|
|||||||
static Future<void> cleanupConnections() async {
|
static Future<void> cleanupConnections() async {
|
||||||
await _channel.invokeMethod('cleanupConnections');
|
await _channel.invokeMethod('cleanupConnections');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start foreground download service to keep downloads running in background
|
||||||
|
static Future<void> startDownloadService({
|
||||||
|
String trackName = '',
|
||||||
|
String artistName = '',
|
||||||
|
int queueCount = 0,
|
||||||
|
}) async {
|
||||||
|
await _channel.invokeMethod('startDownloadService', {
|
||||||
|
'track_name': trackName,
|
||||||
|
'artist_name': artistName,
|
||||||
|
'queue_count': queueCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop foreground download service
|
||||||
|
static Future<void> stopDownloadService() async {
|
||||||
|
await _channel.invokeMethod('stopDownloadService');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update download service notification progress
|
||||||
|
static Future<void> updateDownloadServiceProgress({
|
||||||
|
required String trackName,
|
||||||
|
required String artistName,
|
||||||
|
required int progress,
|
||||||
|
required int total,
|
||||||
|
required int queueCount,
|
||||||
|
}) async {
|
||||||
|
await _channel.invokeMethod('updateDownloadServiceProgress', {
|
||||||
|
'track_name': trackName,
|
||||||
|
'artist_name': artistName,
|
||||||
|
'progress': progress,
|
||||||
|
'total': total,
|
||||||
|
'queue_count': queueCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if download service is running
|
||||||
|
static Future<bool> isDownloadServiceRunning() async {
|
||||||
|
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
||||||
|
return result as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set custom Spotify API credentials
|
||||||
|
/// Pass empty strings to use default credentials
|
||||||
|
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||||
|
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||||
|
'client_id': clientId,
|
||||||
|
'client_secret': clientSecret,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('ShareIntent');
|
||||||
|
|
||||||
|
/// Service to handle incoming share intents from other apps (e.g., Spotify)
|
||||||
|
class ShareIntentService {
|
||||||
|
static final ShareIntentService _instance = ShareIntentService._internal();
|
||||||
|
factory ShareIntentService() => _instance;
|
||||||
|
ShareIntentService._internal();
|
||||||
|
|
||||||
|
final _sharedUrlController = StreamController<String>.broadcast();
|
||||||
|
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
|
||||||
|
bool _initialized = false;
|
||||||
|
String? _pendingUrl; // Store URL received before listener is ready
|
||||||
|
|
||||||
|
/// Stream of shared Spotify URLs
|
||||||
|
Stream<String> get sharedUrlStream => _sharedUrlController.stream;
|
||||||
|
|
||||||
|
/// Get pending URL that was received before listener was ready
|
||||||
|
String? consumePendingUrl() {
|
||||||
|
final url = _pendingUrl;
|
||||||
|
_pendingUrl = null;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the service and start listening for share intents
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
// Listen to media sharing coming from outside the app while the app is in memory
|
||||||
|
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||||
|
_handleSharedMedia,
|
||||||
|
onError: (err) => _log.e('Error: $err'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the media sharing coming from outside the app while the app is closed
|
||||||
|
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
|
||||||
|
if (initialMedia.isNotEmpty) {
|
||||||
|
_handleSharedMedia(initialMedia, isInitial: true);
|
||||||
|
// Tell the library that we are done processing the intent
|
||||||
|
ReceiveSharingIntent.instance.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
|
||||||
|
for (final file in files) {
|
||||||
|
// Check the path - for text shares, the path contains the shared text
|
||||||
|
final textToCheck = file.path;
|
||||||
|
|
||||||
|
final url = _extractSpotifyUrl(textToCheck);
|
||||||
|
if (url != null) {
|
||||||
|
_log.i('Received Spotify URL: $url (initial: $isInitial)');
|
||||||
|
if (isInitial) {
|
||||||
|
// Store for later - listener might not be ready yet
|
||||||
|
_pendingUrl = url;
|
||||||
|
}
|
||||||
|
_sharedUrlController.add(url);
|
||||||
|
return; // Only process first valid URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract Spotify URL from shared text
|
||||||
|
/// Handles various formats:
|
||||||
|
/// - Direct URL: https://open.spotify.com/track/xxx
|
||||||
|
/// - With text: "Check out this song! https://open.spotify.com/track/xxx"
|
||||||
|
/// - Spotify URI: spotify:track:xxx
|
||||||
|
String? _extractSpotifyUrl(String text) {
|
||||||
|
if (text.isEmpty) return null;
|
||||||
|
|
||||||
|
// Check for spotify: URI format
|
||||||
|
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
|
||||||
|
if (uriMatch != null) {
|
||||||
|
return uriMatch.group(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for open.spotify.com URL
|
||||||
|
final urlMatch = RegExp(
|
||||||
|
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||||
|
).firstMatch(text);
|
||||||
|
if (urlMatch != null) {
|
||||||
|
// Return URL without query params for cleaner handling
|
||||||
|
final fullUrl = urlMatch.group(0)!;
|
||||||
|
final queryIndex = fullUrl.indexOf('?');
|
||||||
|
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose resources
|
||||||
|
void dispose() {
|
||||||
|
_mediaSubscription?.cancel();
|
||||||
|
_sharedUrlController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('UpdateChecker');
|
||||||
|
|
||||||
|
class UpdateInfo {
|
||||||
|
final String version;
|
||||||
|
final String changelog;
|
||||||
|
final String downloadUrl;
|
||||||
|
final String? apkDownloadUrl;
|
||||||
|
final DateTime publishedAt;
|
||||||
|
|
||||||
|
const UpdateInfo({
|
||||||
|
required this.version,
|
||||||
|
required this.changelog,
|
||||||
|
required this.downloadUrl,
|
||||||
|
this.apkDownloadUrl,
|
||||||
|
required this.publishedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateChecker {
|
||||||
|
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||||
|
|
||||||
|
static Future<String> _getDeviceArch() async {
|
||||||
|
if (!Platform.isAndroid) return 'unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final cpuInfo = await File('/proc/cpuinfo').readAsString();
|
||||||
|
|
||||||
|
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
|
||||||
|
return 'arm64';
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await Process.run('uname', ['-m']);
|
||||||
|
final arch = result.stdout.toString().trim().toLowerCase();
|
||||||
|
|
||||||
|
if (arch.contains('aarch64') || arch.contains('arm64')) {
|
||||||
|
return 'arm64';
|
||||||
|
} else if (arch.contains('armv7') || arch.contains('arm')) {
|
||||||
|
return 'arm32';
|
||||||
|
} else if (arch.contains('x86_64')) {
|
||||||
|
return 'x86_64';
|
||||||
|
} else if (arch.contains('x86') || arch.contains('i686')) {
|
||||||
|
return 'x86';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'arm64';
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Error detecting arch: $e');
|
||||||
|
return 'arm64';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<UpdateInfo?> checkForUpdate() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(_apiUrl),
|
||||||
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
_log.w('GitHub API returned ${response.statusCode}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final tagName = data['tag_name'] as String? ?? '';
|
||||||
|
final latestVersion = tagName.replaceFirst('v', '');
|
||||||
|
|
||||||
|
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
||||||
|
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = data['body'] as String? ?? 'No changelog available';
|
||||||
|
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||||
|
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
|
||||||
|
|
||||||
|
final deviceArch = await _getDeviceArch();
|
||||||
|
_log.d('Device architecture: $deviceArch');
|
||||||
|
|
||||||
|
String? arm64Url;
|
||||||
|
String? arm32Url;
|
||||||
|
String? universalUrl;
|
||||||
|
|
||||||
|
final assets = data['assets'] as List<dynamic>? ?? [];
|
||||||
|
for (final asset in assets) {
|
||||||
|
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||||
|
if (name.endsWith('.apk')) {
|
||||||
|
final downloadUrl = asset['browser_download_url'] as String?;
|
||||||
|
// Only accept HTTPS URLs for security
|
||||||
|
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
|
||||||
|
if (uri == null || uri.scheme != 'https') {
|
||||||
|
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name.contains('arm64') || name.contains('v8a')) {
|
||||||
|
arm64Url = downloadUrl;
|
||||||
|
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||||
|
arm32Url = downloadUrl;
|
||||||
|
} else if (name.contains('universal')) {
|
||||||
|
universalUrl = downloadUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? apkUrl;
|
||||||
|
if (deviceArch == 'arm64') {
|
||||||
|
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
|
||||||
|
} else if (deviceArch == 'arm32') {
|
||||||
|
apkUrl = arm32Url ?? universalUrl;
|
||||||
|
} else {
|
||||||
|
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
|
||||||
|
|
||||||
|
return UpdateInfo(
|
||||||
|
version: latestVersion,
|
||||||
|
changelog: body,
|
||||||
|
downloadUrl: htmlUrl,
|
||||||
|
apkDownloadUrl: apkUrl,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Error checking for updates: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isNewerVersion(String latest, String current) {
|
||||||
|
try {
|
||||||
|
final latestBase = latest.split('-').first;
|
||||||
|
final currentBase = current.split('-').first;
|
||||||
|
|
||||||
|
final latestParts = latestBase.split('.').map(int.parse).toList();
|
||||||
|
final currentParts = currentBase.split('.').map(int.parse).toList();
|
||||||
|
|
||||||
|
while (latestParts.length < 3) {
|
||||||
|
latestParts.add(0);
|
||||||
|
}
|
||||||
|
while (currentParts.length < 3) {
|
||||||
|
currentParts.add(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
if (latestParts[i] > currentParts[i]) return true;
|
||||||
|
if (latestParts[i] < currentParts[i]) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final latestHasSuffix = latest.contains('-');
|
||||||
|
final currentHasSuffix = current.contains('-');
|
||||||
|
|
||||||
|
if (!latestHasSuffix && currentHasSuffix) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Error comparing versions: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String get currentVersion => AppInfo.version;
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
// Use dynamic colors from wallpaper (Android 12+)
|
// Use dynamic colors from wallpaper (Android 12+)
|
||||||
lightScheme = lightDynamic;
|
lightScheme = lightDynamic;
|
||||||
darkScheme = darkDynamic;
|
darkScheme = darkDynamic;
|
||||||
debugPrint('Using dynamic color from wallpaper');
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to seed color
|
// Fallback to seed color
|
||||||
final seedColor = themeSettings.seedColor;
|
final seedColor = themeSettings.seedColor;
|
||||||
@@ -39,7 +38,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
seedColor: seedColor,
|
seedColor: seedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
);
|
);
|
||||||
debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build themes
|
// Build themes
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
/// Global logger instance for the app
|
||||||
|
/// Uses pretty printer in debug mode for readable output
|
||||||
|
final log = Logger(
|
||||||
|
printer: PrettyPrinter(
|
||||||
|
methodCount: 0,
|
||||||
|
errorMethodCount: 5,
|
||||||
|
lineLength: 80,
|
||||||
|
colors: true,
|
||||||
|
printEmojis: false,
|
||||||
|
dateTimeFormat: DateTimeFormat.none,
|
||||||
|
),
|
||||||
|
level: Level.debug,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Logger with class/tag prefix for better traceability
|
||||||
|
class AppLogger {
|
||||||
|
final String _tag;
|
||||||
|
|
||||||
|
AppLogger(this._tag);
|
||||||
|
|
||||||
|
void d(String message) => log.d('[$_tag] $message');
|
||||||
|
void i(String message) => log.i('[$_tag] $message');
|
||||||
|
void w(String message) => log.w('[$_tag] $message');
|
||||||
|
void e(String message, [Object? error, StackTrace? stackTrace]) =>
|
||||||
|
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A collapsing header widget
|
||||||
|
/// Title collapses from large to small when scrolling
|
||||||
|
class CollapsingHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final bool showBackButton;
|
||||||
|
final Widget? infoCard;
|
||||||
|
final List<Widget> slivers;
|
||||||
|
|
||||||
|
const CollapsingHeader({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.showBackButton = false,
|
||||||
|
this.infoCard,
|
||||||
|
required this.slivers,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 140,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: showBackButton
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final expandRatio = _calculateExpandRatio(constraints, topPadding);
|
||||||
|
final animation = AlwaysStoppedAnimation(expandRatio);
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.zero,
|
||||||
|
title: Container(
|
||||||
|
alignment: Alignment.bottomLeft,
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: Tween<double>(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation),
|
||||||
|
bottom: Tween<double>(begin: 16, end: 24).evaluate(animation),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Info card if provided
|
||||||
|
if (infoCard != null)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: infoCard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content slivers
|
||||||
|
...slivers,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calculateExpandRatio(BoxConstraints constraints, double topPadding) {
|
||||||
|
final maxHeight = 140;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final currentHeight = constraints.maxHeight;
|
||||||
|
final expandRatio = (currentHeight - minHeight) / (maxHeight - minHeight);
|
||||||
|
return expandRatio.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section header for settings
|
||||||
|
class SettingsSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
const SettingsSection({super.key, required this.title});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info card widget (like version info)
|
||||||
|
class InfoCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const InfoCard({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A grouped settings card that connects items together like Android Settings
|
||||||
|
/// Items are connected with no gap between them, only separated when changing groups
|
||||||
|
class SettingsGroup extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
|
||||||
|
const SettingsGroup({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.margin,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Use a more contrasting color for cards
|
||||||
|
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
|
||||||
|
// So we add a slight white overlay to make it more visible
|
||||||
|
// In light mode with dynamic color, we add a slight black overlay for the same reason
|
||||||
|
final cardColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single settings item that can be used inside SettingsGroup
|
||||||
|
class SettingsItem extends StatelessWidget {
|
||||||
|
final IconData? icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final Widget? trailing;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const SettingsItem({
|
||||||
|
super.key,
|
||||||
|
this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.trailing,
|
||||||
|
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: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
trailing!,
|
||||||
|
] else if (onTap != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: icon != null ? 56 : 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A switch settings item for SettingsGroup
|
||||||
|
class SettingsSwitchItem extends StatelessWidget {
|
||||||
|
final IconData? icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool>? onChanged;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const SettingsSwitchItem({
|
||||||
|
super.key,
|
||||||
|
this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
required this.value,
|
||||||
|
this.onChanged,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onChanged != null ? () => onChanged!(!value) : null,
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Switch(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: icon != null ? 56 : 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section header for settings groups
|
||||||
|
class SettingsSectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const SettingsSectionHeader({super.key, required this.title});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
|
import 'package:spotiflac_android/services/apk_downloader.dart';
|
||||||
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
|
|
||||||
|
class UpdateDialog extends StatefulWidget {
|
||||||
|
final UpdateInfo updateInfo;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
final VoidCallback onDisableUpdates;
|
||||||
|
|
||||||
|
const UpdateDialog({
|
||||||
|
super.key,
|
||||||
|
required this.updateInfo,
|
||||||
|
required this.onDismiss,
|
||||||
|
required this.onDisableUpdates,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UpdateDialog> createState() => _UpdateDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
|
bool _isDownloading = false;
|
||||||
|
double _progress = 0;
|
||||||
|
String _statusText = '';
|
||||||
|
|
||||||
|
Future<void> _downloadAndInstall() async {
|
||||||
|
final apkUrl = widget.updateInfo.apkDownloadUrl;
|
||||||
|
|
||||||
|
// If no direct APK URL, open release page
|
||||||
|
if (apkUrl == null) {
|
||||||
|
final uri = Uri.parse(widget.updateInfo.downloadUrl);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isDownloading = true;
|
||||||
|
_progress = 0;
|
||||||
|
_statusText = 'Starting download...';
|
||||||
|
});
|
||||||
|
|
||||||
|
final notificationService = NotificationService();
|
||||||
|
|
||||||
|
final filePath = await ApkDownloader.downloadApk(
|
||||||
|
url: apkUrl,
|
||||||
|
version: widget.updateInfo.version,
|
||||||
|
onProgress: (received, total) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_progress = total > 0 ? received / total : 0;
|
||||||
|
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
_statusText = '$receivedMB / $totalMB MB';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Update notification
|
||||||
|
notificationService.showUpdateDownloadProgress(
|
||||||
|
version: widget.updateInfo.version,
|
||||||
|
received: received,
|
||||||
|
total: total,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filePath != null) {
|
||||||
|
// Cancel progress notification first
|
||||||
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
|
await notificationService.showUpdateDownloadComplete(
|
||||||
|
version: widget.updateInfo.version,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open APK for installation
|
||||||
|
await ApkDownloader.installApk(filePath);
|
||||||
|
} else {
|
||||||
|
// Cancel progress notification first
|
||||||
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
|
await notificationService.showUpdateDownloadFailed();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isDownloading = false;
|
||||||
|
_statusText = 'Download failed';
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Failed to download update')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header with icon
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.system_update_rounded, color: colorScheme.onPrimaryContainer, size: 28),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Version badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Download progress (when downloading)
|
||||||
|
if (_isDownloading) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20, height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _progress,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(_statusText, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
Text('${(_progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
// Changelog section
|
||||||
|
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 180),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
_formatChangelog(widget.updateInfo.changelog),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
if (_isDownloading)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _downloadAndInstall,
|
||||||
|
icon: const Icon(Icons.download_rounded, size: 20),
|
||||||
|
label: const Text('Download & Install'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
widget.onDisableUpdates();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
widget.onDismiss();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: const Text('Later'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format changelog - clean up markdown and extract relevant content
|
||||||
|
String _formatChangelog(String changelog) {
|
||||||
|
var content = changelog;
|
||||||
|
|
||||||
|
// Find content after "What's New" header
|
||||||
|
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
|
||||||
|
if (whatsNewMatch != null) {
|
||||||
|
content = content.substring(whatsNewMatch.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut off at "Downloads" section or horizontal rule
|
||||||
|
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
|
||||||
|
if (cutoffMatch != null) {
|
||||||
|
content = content.substring(0, cutoffMatch.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process line by line for better formatting
|
||||||
|
final lines = content.split('\n');
|
||||||
|
final formattedLines = <String>[];
|
||||||
|
|
||||||
|
for (var line in lines) {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
|
// Check if it's a section header
|
||||||
|
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||||
|
if (sectionMatch != null) {
|
||||||
|
final section = sectionMatch.group(1)?.trim();
|
||||||
|
if (section != null && section.isNotEmpty) {
|
||||||
|
if (formattedLines.isNotEmpty) formattedLines.add('');
|
||||||
|
formattedLines.add(section);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a list item
|
||||||
|
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||||
|
if (listMatch != null) {
|
||||||
|
var itemText = listMatch.group(1) ?? '';
|
||||||
|
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||||
|
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
|
||||||
|
formattedLines.add('• $itemText');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a sub-item
|
||||||
|
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
||||||
|
if (subListMatch != null) {
|
||||||
|
var itemText = subListMatch.group(1) ?? '';
|
||||||
|
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||||
|
formattedLines.add(' - $itemText');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var formatted = formattedLines.join('\n').trim();
|
||||||
|
if (formatted.length > 2000) {
|
||||||
|
formatted = '${formatted.substring(0, 2000)}...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted.isEmpty ? 'See release notes for details.' : formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VersionChip extends StatelessWidget {
|
||||||
|
final String version;
|
||||||
|
final String label;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final bool isNew;
|
||||||
|
|
||||||
|
const _VersionChip({
|
||||||
|
required this.version,
|
||||||
|
required this.label,
|
||||||
|
required this.colorScheme,
|
||||||
|
this.isNew = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'v$version',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isNew ? FontWeight.bold : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show update dialog
|
||||||
|
Future<void> showUpdateDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required UpdateInfo updateInfo,
|
||||||
|
required VoidCallback onDisableUpdates,
|
||||||
|
}) async {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => UpdateDialog(
|
||||||
|
updateInfo: updateInfo,
|
||||||
|
onDismiss: () {},
|
||||||
|
onDisableUpdates: onDisableUpdates,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,26 +5,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "85.0.0"
|
version: "91.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.6.0"
|
version: "8.4.1"
|
||||||
analyzer_buffer:
|
analyzer_buffer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer_buffer
|
name: analyzer_buffer
|
||||||
sha256: f7833bee67c03c37241c67f8741b17cc501b69d9758df7a5a4a13ed6c947be43
|
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.10"
|
version: "0.1.11"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -61,18 +61,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
|
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "4.0.3"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_config
|
name: build_config
|
||||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.2.0"
|
||||||
build_daemon:
|
build_daemon:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,30 +81,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.1"
|
version: "4.1.1"
|
||||||
build_resolvers:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_resolvers
|
|
||||||
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.1"
|
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
|
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "2.10.4"
|
||||||
build_runner_core:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_runner_core
|
|
||||||
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.2.1"
|
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -245,10 +229,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.3"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -382,6 +366,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "19.5.0"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.1.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -552,6 +568,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
logger:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: logger
|
||||||
|
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.2"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -596,10 +620,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mockito
|
name: mockito
|
||||||
sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99"
|
sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.5.0"
|
version: "5.6.1"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -800,6 +824,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "1.5.0"
|
||||||
|
receive_sharing_intent:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: receive_sharing_intent
|
||||||
|
sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.8.1"
|
||||||
riverpod:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -844,18 +876,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.1.4"
|
version: "12.0.1"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: share_plus_platform_interface
|
name: share_plus_platform_interface
|
||||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.2"
|
version: "6.1.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -953,18 +985,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3"
|
sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "4.1.1"
|
||||||
source_helper:
|
source_helper:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_helper
|
name: source_helper
|
||||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.7"
|
version: "1.3.8"
|
||||||
source_map_stack_trace:
|
source_map_stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1109,14 +1141,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.12"
|
version: "0.6.12"
|
||||||
timing:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: timing
|
name: timezone
|
||||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "0.10.1"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.1.1+8
|
version: 2.0.6+36
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -46,17 +46,22 @@ dependencies:
|
|||||||
# Utils
|
# Utils
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
device_info_plus: ^12.3.0
|
device_info_plus: ^12.3.0
|
||||||
share_plus: ^10.1.4
|
share_plus: ^12.0.1
|
||||||
|
receive_sharing_intent: ^1.8.1
|
||||||
|
logger: ^2.5.0
|
||||||
|
|
||||||
# FFmpeg for audio conversion (audio-only version - much smaller)
|
# FFmpeg for audio conversion (audio-only version - much smaller)
|
||||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
build_runner: ^2.4.15
|
build_runner: ^2.10.4
|
||||||
riverpod_generator: ^4.0.0
|
riverpod_generator: ^4.0.0
|
||||||
json_serializable: ^6.11.2
|
json_serializable: ^6.11.2
|
||||||
flutter_launcher_icons: ^0.14.3
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
// This is a basic Flutter widget test.
|
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:spotiflac_android/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||