Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
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
|
||||
|
||||
# Android and iOS build in PARALLEL
|
||||
@@ -33,6 +45,20 @@ jobs:
|
||||
needs: get-version
|
||||
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -86,16 +112,29 @@ jobs:
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release)
|
||||
- name: Build APK (Release - unsigned)
|
||||
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
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cd build/app/outputs/flutter-apk
|
||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||
# Signed files have -signed suffix
|
||||
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.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
|
||||
|
||||
- name: Upload APK artifact
|
||||
@@ -303,6 +342,6 @@ jobs:
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -28,6 +28,7 @@ build/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.metadata
|
||||
*.apk
|
||||
|
||||
# Go backend build artifacts
|
||||
go_backend/*.aar
|
||||
@@ -40,6 +41,10 @@ android/.gradle/
|
||||
android/app/libs/
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
android/*.jks
|
||||
android/*.keystore
|
||||
android/app/*.jks
|
||||
|
||||
# iOS
|
||||
ios/Frameworks/
|
||||
|
||||
@@ -1,5 +1,310 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
@@ -20,6 +325,10 @@
|
||||
- **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
|
||||
|
||||
@@ -11,25 +11,17 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
|
||||
</div>
|
||||
|
||||
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Features
|
||||
|
||||
- Download tracks, albums, and playlists from Spotify links
|
||||
- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
|
||||
- Material Expressive 3 design with dynamic colors
|
||||
- High performance rendering with Impeller (Vulkan)
|
||||
- Concurrent downloads up to 3 simultaneous
|
||||
- Real-time download progress tracking
|
||||
- Download notifications
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/photo_2026-01-02_02-35-09.jpg" width="200" />
|
||||
<img src="assets/images/photo_2026-01-02_02-35-34.jpg" width="200" />
|
||||
<img src="assets/images/photo_2026-01-02_02-35-37.jpg" width="200" />
|
||||
<img src="assets/images/photo_2026-01-02_02-36-23.jpg" width="200" />
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
## Other project
|
||||
@@ -39,6 +31,8 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
|
||||
@@ -5,6 +5,13 @@ plugins {
|
||||
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 {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
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 {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
@@ -30,8 +48,6 @@ android {
|
||||
versionName = flutter.versionName
|
||||
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 {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
@@ -39,8 +55,13 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Enable code shrinking and resource shrinking
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
|
||||
@@ -4,26 +4,30 @@
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
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.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<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
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
@@ -76,6 +80,17 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
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>
|
||||
|
||||
<queries>
|
||||
|
||||
@@ -5,83 +5,211 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
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() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "spotiflac_download_channel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
|
||||
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
|
||||
private const val CHANNEL_ID = "download_channel"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||
|
||||
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() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> startForegroundService()
|
||||
ACTION_STOP -> stopSelf()
|
||||
ACTION_START -> {
|
||||
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
|
||||
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Download Progress",
|
||||
"Download Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows download progress for SpotiFLAC"
|
||||
description = "Shows download progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun updateProgress(trackName: String, progress: Int) {
|
||||
val notification = createNotification(trackName, progress)
|
||||
|
||||
private fun stopForegroundService() {
|
||||
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)
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun createNotification(title: String, progress: Int): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
|
||||
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, DownloadService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
|
||||
val title = if (queueCount > 1) {
|
||||
"Downloading $queueCount tracks"
|
||||
} else if (currentTrackName.isNotEmpty()) {
|
||||
currentTrackName
|
||||
} else {
|
||||
"Downloading..."
|
||||
}
|
||||
val stopPendingIntent = PendingIntent.getService(
|
||||
this, 0, stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("SpotiFLAC")
|
||||
.setContentText(title)
|
||||
|
||||
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
|
||||
currentArtistName
|
||||
} else if (total > 0) {
|
||||
val progressPercent = (progress * 100 / total).toInt()
|
||||
val progressMB = progress / (1024.0 * 1024.0)
|
||||
val totalMB = total / (1024.0 * 1024.0)
|
||||
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)
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
|
||||
.build()
|
||||
.setOngoing(true)
|
||||
.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
|
||||
|
||||
import android.content.Intent
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -14,6 +15,12 @@ class MainActivity: FlutterActivity() {
|
||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||
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) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
@@ -43,6 +50,15 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
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" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
@@ -141,8 +157,9 @@ class MainActivity: FlutterActivity() {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -160,6 +177,37 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
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()
|
||||
}
|
||||
} 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>
|
||||
@@ -20,7 +20,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
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")
|
||||
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 191 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:
|
||||
@@ -203,12 +203,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -232,11 +227,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -245,14 +237,14 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
var bytesWritten int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(out, itemID)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
pw := NewProgressWriter(out)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
bytesWritten, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
@@ -262,38 +254,45 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
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
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return "EXISTS:" + existingFile, nil
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Get Amazon URL from SongLink
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
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 == "" {
|
||||
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
|
||||
if req.OutputDir != "." {
|
||||
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)
|
||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
@@ -310,12 +309,19 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return "EXISTS:" + outputPath, nil
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// Download file with item ID for progress tracking
|
||||
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)
|
||||
@@ -374,5 +380,24 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ func ParseSpotifyURL(url string) (string, error) {
|
||||
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
|
||||
// Returns JSON with track/album/playlist data
|
||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
@@ -70,6 +76,26 @@ func SearchSpotify(query string, limit int) (string, error) {
|
||||
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
|
||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
@@ -116,6 +142,17 @@ type DownloadResponse struct {
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,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
|
||||
@@ -134,16 +171,40 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
var filePath string
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
switch req.Service {
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
return errorResponse("Unknown service: " + req.Service)
|
||||
}
|
||||
@@ -153,21 +214,25 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: filePath[7:],
|
||||
FilePath: result.FilePath[7:],
|
||||
AlreadyExists: true,
|
||||
Service: req.Service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Download complete",
|
||||
FilePath: filePath,
|
||||
Success: true,
|
||||
Message: "Download complete",
|
||||
FilePath: result.FilePath,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: req.Service,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
@@ -209,35 +274,63 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
for _, service := range services {
|
||||
req.Service = service
|
||||
|
||||
var filePath string
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
switch service {
|
||||
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":
|
||||
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":
|
||||
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 {
|
||||
// Check if file already exists
|
||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: filePath[7:],
|
||||
FilePath: result.FilePath[7:],
|
||||
AlreadyExists: true,
|
||||
Service: service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Downloaded from " + service,
|
||||
FilePath: filePath,
|
||||
Success: true,
|
||||
Message: "Downloaded from " + service,
|
||||
FilePath: result.FilePath,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
@@ -346,14 +439,24 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
lrcContent := convertToLRC(lyricsData)
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -335,3 +335,92 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DownloadProgress represents current download progress (legacy single download)
|
||||
// DownloadProgress represents current download progress
|
||||
// Now unified - returns data from multi-progress system
|
||||
type DownloadProgress struct {
|
||||
CurrentFile string `json:"current_file"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -13,6 +14,7 @@ type DownloadProgress struct {
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
}
|
||||
|
||||
// ItemProgress represents progress for a single download item
|
||||
@@ -22,6 +24,7 @@ type ItemProgress struct {
|
||||
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
|
||||
@@ -30,28 +33,40 @@ type MultiProgress struct {
|
||||
}
|
||||
|
||||
var (
|
||||
currentProgress DownloadProgress
|
||||
progressMu sync.RWMutex
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
// Multi-download progress tracking
|
||||
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 (legacy)
|
||||
// getProgress returns current download progress from multi-progress system
|
||||
// Returns first active item's progress for backward compatibility
|
||||
func getProgress() DownloadProgress {
|
||||
progressMu.RLock()
|
||||
defer progressMu.RUnlock()
|
||||
return currentProgress
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
// 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{}
|
||||
}
|
||||
|
||||
// GetMultiProgress returns progress for all active downloads as JSON
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
|
||||
jsonBytes, err := json.Marshal(multiProgress)
|
||||
if err != nil {
|
||||
return "{\"items\":{}}"
|
||||
@@ -63,7 +78,7 @@ func GetMultiProgress() string {
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
jsonBytes, _ := json.Marshal(item)
|
||||
return string(jsonBytes)
|
||||
@@ -75,13 +90,14 @@ func GetItemProgress(itemID string) string {
|
||||
func StartItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
multiProgress.Items[itemID] = &ItemProgress{
|
||||
ItemID: itemID,
|
||||
BytesTotal: 0,
|
||||
BytesReceived: 0,
|
||||
Progress: 0,
|
||||
IsDownloading: true,
|
||||
Status: "downloading",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +105,7 @@ func StartItemProgress(itemID string) {
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesTotal = total
|
||||
}
|
||||
@@ -99,7 +115,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
||||
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 {
|
||||
@@ -112,10 +128,38 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +167,7 @@ func CompleteItemProgress(itemID string) {
|
||||
func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
}
|
||||
|
||||
@@ -131,45 +175,10 @@ func RemoveItemProgress(itemID string) {
|
||||
func ClearAllItemProgress() {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
|
||||
// Legacy functions for backward compatibility
|
||||
|
||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
||||
func SetDownloadProgress(mbDownloaded float64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.Progress = mbDownloaded
|
||||
currentProgress.IsDownloading = true
|
||||
}
|
||||
|
||||
// SetDownloadSpeed sets the current download speed
|
||||
func SetDownloadSpeed(speedMBps float64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.Speed = speedMBps
|
||||
}
|
||||
|
||||
// SetCurrentFile sets the current file being downloaded and resets progress
|
||||
func SetCurrentFile(filename string) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.BytesReceived = 0
|
||||
currentProgress.BytesTotal = 0
|
||||
currentProgress.Progress = 0
|
||||
currentProgress.CurrentFile = filename
|
||||
currentProgress.IsDownloading = true
|
||||
}
|
||||
|
||||
// ResetProgress resets the download progress
|
||||
func ResetProgress() {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress = DownloadProgress{}
|
||||
}
|
||||
|
||||
// setDownloadDir sets the default download directory
|
||||
func setDownloadDir(path string) error {
|
||||
downloadDirMu.Lock()
|
||||
@@ -185,64 +194,6 @@ func getDownloadDir() string {
|
||||
return downloadDir
|
||||
}
|
||||
|
||||
// SetDownloading sets the download status
|
||||
func SetDownloading(status bool) {
|
||||
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 (legacy single)
|
||||
type ProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
total int64
|
||||
current int64
|
||||
}
|
||||
|
||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
||||
SetBytesReceived(0)
|
||||
return &ProgressWriter{
|
||||
writer: w,
|
||||
current: 0,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
pw.current += int64(n)
|
||||
pw.total += int64(n)
|
||||
SetBytesReceived(pw.current)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetTotal returns total bytes written
|
||||
func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
|
||||
@@ -262,12 +262,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -289,11 +284,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -302,24 +294,31 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
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
|
||||
func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return "EXISTS:" + existingFile, nil
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
var track *QobuzTrack
|
||||
@@ -340,7 +339,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
@@ -357,7 +356,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
|
||||
// Check if file already exists
|
||||
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
|
||||
@@ -374,15 +373,27 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
}
|
||||
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
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
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 with item ID for progress tracking
|
||||
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
|
||||
@@ -435,5 +446,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"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,11 +21,28 @@ const (
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
albumCacheTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
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
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
@@ -32,31 +50,76 @@ type SpotifyMetadataClient struct {
|
||||
clientSecret string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
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
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// Decode credentials from base64
|
||||
clientID := ""
|
||||
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)
|
||||
}
|
||||
// Get credentials (custom or default)
|
||||
clientID, clientSecret := getCredentials()
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
rng: rand.New(src),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
}
|
||||
c.userAgent = c.randomUserAgent()
|
||||
return c
|
||||
@@ -131,6 +194,32 @@ type PlaylistResponsePayload struct {
|
||||
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
|
||||
type TrackResponse struct {
|
||||
Track TrackMetadata `json:"track"`
|
||||
@@ -142,6 +231,21 @@ type SearchResult struct {
|
||||
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 string
|
||||
ID string
|
||||
@@ -212,6 +316,8 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
||||
return c.fetchAlbum(ctx, parsed.ID, token)
|
||||
case "playlist":
|
||||
return c.fetchPlaylist(ctx, parsed.ID, token)
|
||||
case "artist":
|
||||
return c.fetchArtist(ctx, parsed.ID, token)
|
||||
default:
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var data trackFull
|
||||
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) {
|
||||
// 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 {
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
@@ -344,10 +550,20 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
})
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
result := &AlbumResponsePayload{
|
||||
AlbumInfo: info,
|
||||
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) {
|
||||
@@ -405,6 +621,106 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
}, 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 {
|
||||
var data struct {
|
||||
ExternalID externalID `json:"external_ids"`
|
||||
|
||||
@@ -335,33 +335,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
queries = append(queries, trackName)
|
||||
}
|
||||
|
||||
// Strategy 3: Romaji versions if Japanese detected
|
||||
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
|
||||
// Strategy 3: Artist only as last resort
|
||||
if artistName != "" {
|
||||
queries = append(queries, artistName)
|
||||
}
|
||||
@@ -483,11 +457,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
|
||||
// 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 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
@@ -519,7 +500,12 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
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)
|
||||
@@ -529,7 +515,13 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
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 +529,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
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
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||
apis := t.GetAvailableAPIs()
|
||||
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 {
|
||||
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)
|
||||
@@ -646,12 +638,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
}
|
||||
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -673,11 +660,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -686,13 +670,13 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -709,12 +693,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
|
||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||
if directURL != "" {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -736,11 +715,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
}
|
||||
|
||||
// Set total bytes for progress tracking
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -749,13 +725,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -828,13 +804,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
return nil
|
||||
}
|
||||
|
||||
// TidalDownloadResult contains download result with quality info
|
||||
type TidalDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// downloadFromTidal downloads a track using the request parameters
|
||||
func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return "EXISTS:" + existingFile, nil
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
var track *TidalTrack
|
||||
@@ -867,7 +850,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
@@ -884,7 +867,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
|
||||
// Check if file already exists
|
||||
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)
|
||||
@@ -895,14 +878,24 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
||||
|
||||
// Get download URL using parallel API requests
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Log actual quality received
|
||||
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
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
|
||||
@@ -915,7 +908,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
// 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
|
||||
@@ -973,5 +966,9 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
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 }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
@@ -155,7 +164,8 @@ import Gobackend // Import Go framework
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
let trackName = args["track_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 }
|
||||
return response
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '1.5.0';
|
||||
static const String buildNumber = '14';
|
||||
static const String version = '2.0.4';
|
||||
static const String buildNumber = '34';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
static const String copyright = '© 2026 SpotiFLAC';
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/app.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() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -10,6 +11,9 @@ void main() async {
|
||||
// Initialize notification service
|
||||
await NotificationService().initialize();
|
||||
|
||||
// Initialize share intent service
|
||||
await ShareIntentService().initialize();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: const _EagerInitialization(
|
||||
|
||||
@@ -7,6 +7,7 @@ part 'download_item.g.dart';
|
||||
enum DownloadStatus {
|
||||
queued,
|
||||
downloading,
|
||||
finalizing, // Embedding metadata, cover, lyrics
|
||||
completed,
|
||||
failed,
|
||||
skipped,
|
||||
@@ -22,6 +23,7 @@ class DownloadItem {
|
||||
final String? filePath;
|
||||
final String? error;
|
||||
final DateTime createdAt;
|
||||
final String? qualityOverride; // Override quality for this specific download
|
||||
|
||||
const DownloadItem({
|
||||
required this.id,
|
||||
@@ -32,6 +34,7 @@ class DownloadItem {
|
||||
this.filePath,
|
||||
this.error,
|
||||
required this.createdAt,
|
||||
this.qualityOverride,
|
||||
});
|
||||
|
||||
DownloadItem copyWith({
|
||||
@@ -43,6 +46,7 @@ class DownloadItem {
|
||||
String? filePath,
|
||||
String? error,
|
||||
DateTime? createdAt,
|
||||
String? qualityOverride,
|
||||
}) {
|
||||
return DownloadItem(
|
||||
id: id ?? this.id,
|
||||
@@ -53,6 +57,7 @@ class DownloadItem {
|
||||
filePath: filePath ?? this.filePath,
|
||||
error: error ?? this.error,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
||||
filePath: json['filePath'] as String?,
|
||||
error: json['error'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
qualityOverride: json['qualityOverride'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
@@ -29,11 +30,13 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
'filePath': instance.filePath,
|
||||
'error': instance.error,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'qualityOverride': instance.qualityOverride,
|
||||
};
|
||||
|
||||
const _$DownloadStatusEnumMap = {
|
||||
DownloadStatus.queued: 'queued',
|
||||
DownloadStatus.downloading: 'downloading',
|
||||
DownloadStatus.finalizing: 'finalizing',
|
||||
DownloadStatus.completed: 'completed',
|
||||
DownloadStatus.failed: 'failed',
|
||||
DownloadStatus.skipped: 'skipped',
|
||||
|
||||
@@ -14,6 +14,13 @@ class AppSettings {
|
||||
final bool isFirstLaunch;
|
||||
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({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -26,6 +33,13 @@ class AppSettings {
|
||||
this.isFirstLaunch = true,
|
||||
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({
|
||||
@@ -39,6 +53,13 @@ class AppSettings {
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
bool? checkForUpdates,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
String? historyViewMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
bool? useCustomSpotifyCredentials,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -51,6 +72,13 @@ class AppSettings {
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
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) =>
|
||||
@@ -31,4 +39,11 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'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,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,10 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('DownloadQueue');
|
||||
final _historyLog = AppLogger('DownloadHistory');
|
||||
|
||||
// Download History Item model
|
||||
class DownloadHistoryItem {
|
||||
@@ -95,8 +99,16 @@ class DownloadHistoryItem {
|
||||
// Download History State
|
||||
class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||
|
||||
const DownloadHistoryState({this.items = const []});
|
||||
DownloadHistoryState({this.items = const []})
|
||||
: _downloadedSpotifyIds = items
|
||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
.map((item) => item.spotifyId!)
|
||||
.toSet();
|
||||
|
||||
/// Check if a track has been downloaded (by Spotify ID)
|
||||
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId);
|
||||
|
||||
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||
return DownloadHistoryState(items: items ?? this.items);
|
||||
@@ -112,7 +124,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
DownloadHistoryState build() {
|
||||
// Load history from storage on init
|
||||
_loadFromStorageSync();
|
||||
return const DownloadHistoryState();
|
||||
return DownloadHistoryState();
|
||||
}
|
||||
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
@@ -132,12 +144,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(items: items);
|
||||
print('[DownloadHistory] Loaded ${items.length} items from storage');
|
||||
_historyLog.i('Loaded ${items.length} items from storage');
|
||||
} else {
|
||||
print('[DownloadHistory] No history found in storage');
|
||||
_historyLog.d('No history found in storage');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[DownloadHistory] Failed to load history: $e');
|
||||
_historyLog.e('Failed to load history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +158,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = state.items.map((e) => e.toJson()).toList();
|
||||
await prefs.setString(_storageKey, jsonEncode(jsonList));
|
||||
print('[DownloadHistory] Saved ${state.items.length} items to storage');
|
||||
_historyLog.d('Saved ${state.items.length} items to storage');
|
||||
} catch (e) {
|
||||
print('[DownloadHistory] Failed to save history: $e');
|
||||
_historyLog.e('Failed to save history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,8 +181,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_saveToStorage();
|
||||
}
|
||||
|
||||
/// Remove item from history by Spotify ID
|
||||
void removeBySpotifyId(String spotifyId) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
|
||||
);
|
||||
_saveToStorage();
|
||||
_historyLog.d('Removed item with spotifyId: $spotifyId');
|
||||
}
|
||||
|
||||
/// Get history item by Spotify ID
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
|
||||
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
state = const DownloadHistoryState();
|
||||
state = DownloadHistoryState();
|
||||
_saveToStorage();
|
||||
}
|
||||
}
|
||||
@@ -238,54 +264,99 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Timer? _progressTimer;
|
||||
int _downloadCount = 0; // Counter for connection cleanup
|
||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||
int _completedInSession = 0; // Track completed downloads in current session
|
||||
int _failedInSession = 0; // Track failed downloads in current session
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
// Initialize output directory asynchronously
|
||||
// Cleanup timer when provider is disposed
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
});
|
||||
|
||||
// Initialize output directory and load persisted queue asynchronously
|
||||
Future.microtask(() async {
|
||||
await _initOutputDir();
|
||||
await _loadQueueFromStorage();
|
||||
});
|
||||
return const DownloadQueueState();
|
||||
}
|
||||
|
||||
void _startProgressPolling(String itemId) {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||
try {
|
||||
final progress = await PlatformBridge.getDownloadProgress();
|
||||
final bytesReceived = progress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = progress['bytes_total'] as int? ?? 0;
|
||||
final isDownloading = progress['is_downloading'] as bool? ?? false;
|
||||
/// Load persisted queue from storage (for app restart recovery)
|
||||
Future<void> _loadQueueFromStorage() async {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_queueStorageKey);
|
||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
|
||||
if (isDownloading && bytesTotal > 0) {
|
||||
final percentage = bytesReceived / bytesTotal;
|
||||
updateProgress(itemId, percentage);
|
||||
|
||||
// Update notification with progress
|
||||
final currentItem = state.currentDownload;
|
||||
if (currentItem != null) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
);
|
||||
// Reset downloading items to queued (they were interrupted)
|
||||
final restoredItems = items.map((item) {
|
||||
if (item.status == DownloadStatus.downloading) {
|
||||
return item.copyWith(status: DownloadStatus.queued, progress: 0);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
|
||||
// Only restore queued/downloading items (not completed/failed/skipped)
|
||||
final pendingItems = restoredItems.where((item) =>
|
||||
item.status == DownloadStatus.queued
|
||||
).toList();
|
||||
|
||||
if (pendingItems.isNotEmpty) {
|
||||
state = state.copyWith(items: pendingItems);
|
||||
_log.i('Restored ${pendingItems.length} pending items from storage');
|
||||
|
||||
// Log progress
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
print('[DownloadQueue] Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
||||
// Auto-resume queue processing
|
||||
Future.microtask(() => _processQueue());
|
||||
} else {
|
||||
_log.d('No pending items to restore');
|
||||
// Clear storage since nothing to restore
|
||||
await prefs.remove(_queueStorageKey);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore polling errors
|
||||
} else {
|
||||
_log.d('No queue found in storage');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
_log.e('Failed to load queue from storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Start multi-progress polling for concurrent downloads
|
||||
/// Save current queue to storage (only pending items)
|
||||
Future<void> _saveQueueToStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Only persist queued and downloading items
|
||||
final pendingItems = state.items.where((item) =>
|
||||
item.status == DownloadStatus.queued ||
|
||||
item.status == DownloadStatus.downloading
|
||||
).toList();
|
||||
|
||||
if (pendingItems.isEmpty) {
|
||||
// Clear storage if no pending items
|
||||
await prefs.remove(_queueStorageKey);
|
||||
_log.d('Cleared queue storage (no pending items)');
|
||||
} else {
|
||||
final jsonList = pendingItems.map((e) => e.toJson()).toList();
|
||||
await prefs.setString(_queueStorageKey, jsonEncode(jsonList));
|
||||
_log.d('Saved ${pendingItems.length} pending items to storage');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to save queue to storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||
@@ -293,12 +364,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
bool hasFinalizingItem = false;
|
||||
String? finalizingTrackName;
|
||||
String? finalizingArtistName;
|
||||
|
||||
for (final entry in items.entries) {
|
||||
final itemId = entry.key;
|
||||
final itemProgress = entry.value as Map<String, dynamic>;
|
||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||
|
||||
// Check if status is "finalizing" (embedding metadata)
|
||||
// Only trust finalizing status if bytesTotal > 0 (download actually happened)
|
||||
if (status == 'finalizing' && bytesTotal > 0) {
|
||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||
|
||||
// Track finalizing item for notification
|
||||
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||
if (currentItem != null) {
|
||||
hasFinalizingItem = true;
|
||||
finalizingTrackName = currentItem.track.name;
|
||||
finalizingArtistName = currentItem.track.artistName;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDownloading && bytesTotal > 0) {
|
||||
final percentage = bytesReceived / bytesTotal;
|
||||
@@ -307,26 +398,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Log progress for each item
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
print('[DownloadQueue] Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
||||
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
||||
}
|
||||
}
|
||||
|
||||
// Update notification with first active download
|
||||
// Show finalizing notification if any item is finalizing (takes priority)
|
||||
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: finalizingTrackName,
|
||||
artistName: finalizingArtistName ?? '',
|
||||
);
|
||||
return; // Don't show download progress notification
|
||||
}
|
||||
|
||||
// Update notification with active downloads
|
||||
if (items.isNotEmpty) {
|
||||
final firstEntry = items.entries.first;
|
||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||
|
||||
// Find the item to get track info
|
||||
// Find downloading items (not finalizing)
|
||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
||||
if (downloadingItems.isNotEmpty) {
|
||||
// Show single track name if only 1 download, otherwise show count
|
||||
final trackName = downloadingItems.length == 1
|
||||
? downloadingItems.first.track.name
|
||||
: '${downloadingItems.length} downloads';
|
||||
final artistName = downloadingItems.length == 1
|
||||
? downloadingItems.first.track.artistName
|
||||
: 'Downloading...';
|
||||
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: '${downloadingItems.length} downloads',
|
||||
artistName: 'Downloading...',
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||
);
|
||||
|
||||
// Update foreground service notification (Android)
|
||||
if (Platform.isAndroid) {
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
trackName: downloadingItems.first.track.name,
|
||||
artistName: downloadingItems.first.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||
queueCount: state.queuedCount,
|
||||
).catchError((_) {}); // Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -386,6 +505,52 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(outputDir: dir);
|
||||
}
|
||||
|
||||
/// Build output directory based on folder organization setting
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization) async {
|
||||
String baseDir = state.outputDir;
|
||||
|
||||
if (folderOrganization == 'none') {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
// Sanitize folder names (remove invalid characters)
|
||||
String sanitize(String name) {
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
|
||||
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
|
||||
.trim();
|
||||
}
|
||||
|
||||
String subPath = '';
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
subPath = artistName;
|
||||
break;
|
||||
case 'album':
|
||||
final albumName = sanitize(track.albumName);
|
||||
subPath = albumName;
|
||||
break;
|
||||
case 'artist_album':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
final albumName = sanitize(track.albumName);
|
||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
}
|
||||
|
||||
if (subPath.isNotEmpty) {
|
||||
final fullPath = '$baseDir${Platform.pathSeparator}$subPath';
|
||||
final dir = Directory(fullPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
_log.d('Created folder: $fullPath');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
||||
@@ -396,7 +561,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
String addToQueue(Track track, String service) {
|
||||
String addToQueue(Track track, String service, {String? qualityOverride}) {
|
||||
// Sync settings before adding to queue
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
@@ -407,9 +572,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
track: track,
|
||||
service: service,
|
||||
createdAt: DateTime.now(),
|
||||
qualityOverride: qualityOverride,
|
||||
);
|
||||
|
||||
state = state.copyWith(items: [...state.items, item]);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
if (!state.isProcessing) {
|
||||
// Run in microtask to not block UI
|
||||
@@ -419,7 +586,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return id;
|
||||
}
|
||||
|
||||
void addMultipleToQueue(List<Track> tracks, String service) {
|
||||
void addMultipleToQueue(List<Track> tracks, String service, {String? qualityOverride}) {
|
||||
// Sync settings before adding to queue
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
@@ -431,10 +598,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
track: track,
|
||||
service: service,
|
||||
createdAt: DateTime.now(),
|
||||
qualityOverride: qualityOverride,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(items: [...state.items, ...newItems]);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
if (!state.isProcessing) {
|
||||
// Run in microtask to not block UI
|
||||
@@ -456,6 +625,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
|
||||
// Persist queue when status changes to completed/failed/skipped (item removed from pending)
|
||||
if (status == DownloadStatus.completed ||
|
||||
status == DownloadStatus.failed ||
|
||||
status == DownloadStatus.skipped) {
|
||||
_saveQueueToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
void updateProgress(String id, double progress) {
|
||||
@@ -474,10 +650,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
).toList();
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
state = state.copyWith(items: [], isPaused: false);
|
||||
_saveQueueToStorage(); // Clear persisted queue
|
||||
}
|
||||
|
||||
/// Pause the download queue
|
||||
@@ -485,7 +663,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (state.isProcessing && !state.isPaused) {
|
||||
state = state.copyWith(isPaused: true);
|
||||
_notificationService.cancelDownloadNotification();
|
||||
print('[DownloadQueue] Queue paused');
|
||||
_log.i('Queue paused');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,7 +671,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
void resumeQueue() {
|
||||
if (state.isPaused) {
|
||||
state = state.copyWith(isPaused: false);
|
||||
print('[DownloadQueue] Queue resumed');
|
||||
_log.i('Queue resumed');
|
||||
// If there are still queued items, continue processing
|
||||
if (state.queuedCount > 0 && !state.isProcessing) {
|
||||
Future.microtask(() => _processQueue());
|
||||
@@ -519,6 +697,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return item;
|
||||
}).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
// Start processing if not already
|
||||
if (!state.isProcessing) {
|
||||
@@ -530,6 +709,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
void removeItem(String id) {
|
||||
final items = state.items.where((item) => item.id != id).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
}
|
||||
|
||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||
@@ -548,14 +728,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final sink = file.openWrite();
|
||||
await response.pipe(sink);
|
||||
await sink.close();
|
||||
print('[DownloadQueue] Cover downloaded to: $coverPath');
|
||||
_log.d('Cover downloaded to: $coverPath');
|
||||
} else {
|
||||
print('[DownloadQueue] Failed to download cover: HTTP ${response.statusCode}');
|
||||
_log.w('Failed to download cover: HTTP ${response.statusCode}');
|
||||
coverPath = null;
|
||||
}
|
||||
httpClient.close();
|
||||
} catch (e) {
|
||||
print('[DownloadQueue] Failed to download cover: $e');
|
||||
_log.e('Failed to download cover: $e');
|
||||
coverPath = null;
|
||||
}
|
||||
}
|
||||
@@ -575,10 +755,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Replace original with temp
|
||||
await File(flacPath).delete();
|
||||
await File(tempOutput).rename(flacPath);
|
||||
print('[DownloadQueue] Cover embedded via FFmpeg');
|
||||
_log.d('Cover embedded via FFmpeg');
|
||||
} else {
|
||||
// Try alternative method using metaflac-style embedding
|
||||
print('[DownloadQueue] FFmpeg cover embed failed, trying alternative...');
|
||||
_log.w('FFmpeg cover embed failed, trying alternative...');
|
||||
// Clean up temp file if exists
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
@@ -592,7 +772,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[DownloadQueue] Failed to embed metadata: $e');
|
||||
_log.e('Failed to embed metadata: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,20 +780,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (state.isProcessing) return; // Prevent multiple concurrent processing
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
print('[DownloadQueue] Starting queue processing...');
|
||||
_log.i('Starting queue processing...');
|
||||
|
||||
// Track total items at start for notification
|
||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
||||
_completedInSession = 0;
|
||||
_failedInSession = 0;
|
||||
|
||||
// Start foreground service to keep downloads running in background (Android only)
|
||||
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||
final firstItem = state.items.firstWhere(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
orElse: () => state.items.first,
|
||||
);
|
||||
try {
|
||||
await PlatformBridge.startDownloadService(
|
||||
trackName: firstItem.track.name,
|
||||
artistName: firstItem.track.artistName,
|
||||
queueCount: _totalQueuedAtStart,
|
||||
);
|
||||
_log.d('Foreground service started');
|
||||
} catch (e) {
|
||||
_log.e('Failed to start foreground service: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure output directory is initialized before processing
|
||||
if (state.outputDir.isEmpty) {
|
||||
print('[DownloadQueue] Output dir empty, initializing...');
|
||||
_log.d('Output dir empty, initializing...');
|
||||
await _initOutputDir();
|
||||
}
|
||||
|
||||
// If still empty, use fallback
|
||||
if (state.outputDir.isEmpty) {
|
||||
print('[DownloadQueue] Using fallback directory...');
|
||||
_log.d('Using fallback directory...');
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
||||
if (!await musicDir.exists()) {
|
||||
@@ -622,8 +822,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Output directory: ${state.outputDir}');
|
||||
print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}');
|
||||
_log.d('Output directory: ${state.outputDir}');
|
||||
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||
|
||||
// Use parallel processing if concurrentDownloads > 1
|
||||
if (state.concurrentDownloads > 1) {
|
||||
@@ -634,37 +834,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_stopProgressPolling();
|
||||
|
||||
// Stop foreground service (Android only)
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await PlatformBridge.stopDownloadService();
|
||||
_log.d('Foreground service stopped');
|
||||
} catch (e) {
|
||||
_log.e('Failed to stop foreground service: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Final cleanup after queue finishes
|
||||
if (_downloadCount > 0) {
|
||||
print('[DownloadQueue] Final connection cleanup...');
|
||||
_log.d('Final connection cleanup...');
|
||||
try {
|
||||
await PlatformBridge.cleanupConnections();
|
||||
} catch (e) {
|
||||
print('[DownloadQueue] Final cleanup failed: $e');
|
||||
_log.e('Final cleanup failed: $e');
|
||||
}
|
||||
_downloadCount = 0;
|
||||
}
|
||||
|
||||
// Show queue completion notification
|
||||
final completedCount = state.completedCount;
|
||||
final failedCount = state.failedCount;
|
||||
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
|
||||
if (_totalQueuedAtStart > 0) {
|
||||
await _notificationService.showQueueComplete(
|
||||
completedCount: completedCount,
|
||||
failedCount: failedCount,
|
||||
completedCount: _completedInSession,
|
||||
failedCount: _failedInSession,
|
||||
);
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Queue processing finished');
|
||||
_log.i('Queue processing finished');
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
}
|
||||
|
||||
/// Sequential download processing (original behavior)
|
||||
/// Sequential download processing (uses multi-progress system with single item)
|
||||
Future<void> _processQueueSequential() async {
|
||||
// Start multi-progress polling (works for both sequential and parallel)
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
// Check if paused
|
||||
if (state.isPaused) {
|
||||
print('[DownloadQueue] Queue is paused, waiting...');
|
||||
_log.d('Queue is paused, waiting...');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
continue;
|
||||
}
|
||||
@@ -680,12 +892,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (nextItem.id.isEmpty) {
|
||||
print('[DownloadQueue] No more items to process');
|
||||
_log.d('No more items to process');
|
||||
break;
|
||||
}
|
||||
|
||||
await _downloadSingleItem(nextItem);
|
||||
|
||||
// Clear item progress after download completes
|
||||
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
|
||||
}
|
||||
|
||||
// Stop polling when queue is done
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
/// Parallel download processing with worker pool
|
||||
@@ -693,13 +911,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final maxConcurrent = state.concurrentDownloads;
|
||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||
|
||||
// Start multi-progress polling for concurrent downloads
|
||||
// Start multi-progress polling (shared with sequential mode)
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
// Check if paused - don't start new downloads but let active ones finish
|
||||
if (state.isPaused) {
|
||||
print('[DownloadQueue] Queue is paused, waiting for active downloads...');
|
||||
_log.d('Queue is paused, waiting for active downloads...');
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
await Future.any(activeDownloads.values);
|
||||
} else {
|
||||
@@ -712,7 +930,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
|
||||
|
||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||
print('[DownloadQueue] No more items to process');
|
||||
_log.d('No more items to process');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -731,7 +949,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
});
|
||||
|
||||
activeDownloads[item.id] = future;
|
||||
print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
|
||||
_log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
|
||||
}
|
||||
|
||||
// Wait for at least one download to complete before checking for more
|
||||
@@ -744,27 +962,35 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
await Future.wait(activeDownloads.values);
|
||||
}
|
||||
|
||||
// Stop polling when queue is done
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
/// Download a single item (used by both sequential and parallel processing)
|
||||
Future<void> _downloadSingleItem(DownloadItem item) async {
|
||||
print('[DownloadQueue] Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
print('[DownloadQueue] Cover URL: ${item.track.coverUrl}');
|
||||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||
|
||||
// Only set currentDownload for sequential mode (for progress polling)
|
||||
if (state.concurrentDownloads == 1) {
|
||||
state = state.copyWith(currentDownload: item);
|
||||
_startProgressPolling(item.id);
|
||||
}
|
||||
// Set currentDownload for UI reference
|
||||
state = state.copyWith(currentDownload: item);
|
||||
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
|
||||
try {
|
||||
// Get folder organization setting and build output directory
|
||||
final settings = ref.read(settingsProvider);
|
||||
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
|
||||
|
||||
// Use quality override if set, otherwise use default from settings
|
||||
final quality = item.qualityOverride ?? state.audioQuality;
|
||||
|
||||
Map<String, dynamic> result;
|
||||
|
||||
if (state.autoFallback) {
|
||||
print('[DownloadQueue] Using auto-fallback mode');
|
||||
print('[DownloadQueue] Quality: ${state.audioQuality}');
|
||||
_log.d('Using auto-fallback mode');
|
||||
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
||||
_log.d('Output dir: $outputDir');
|
||||
result = await PlatformBridge.downloadWithFallback(
|
||||
isrc: item.track.isrc ?? '',
|
||||
spotifyId: item.track.id,
|
||||
@@ -773,9 +999,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumName: item.track.albumName,
|
||||
albumArtist: item.track.albumArtist,
|
||||
coverUrl: item.track.coverUrl,
|
||||
outputDir: state.outputDir,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
quality: quality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
@@ -792,62 +1018,113 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumName: item.track.albumName,
|
||||
albumArtist: item.track.albumArtist,
|
||||
coverUrl: item.track.coverUrl,
|
||||
outputDir: state.outputDir,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
quality: quality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
);
|
||||
}
|
||||
|
||||
// Stop progress polling for this item (sequential mode only)
|
||||
if (state.concurrentDownloads == 1) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Result: $result');
|
||||
_log.d('Result: $result');
|
||||
|
||||
// Check if item was cancelled while downloading
|
||||
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
||||
if (currentItem.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled, skipping result processing');
|
||||
// Delete the downloaded file if it exists
|
||||
final filePath = result['file_path'] as String?;
|
||||
if (filePath != null && result['success'] == true) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
_log.d('Deleted cancelled download file: $filePath');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to delete cancelled file: $e');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result['success'] == true) {
|
||||
var filePath = result['file_path'] as String?;
|
||||
print('[DownloadQueue] Download success, file: $filePath');
|
||||
_log.i('Download success, file: $filePath');
|
||||
|
||||
// Get actual quality from response (if available)
|
||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||
String actualQuality = quality; // Default to requested quality
|
||||
|
||||
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
||||
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
||||
? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1)
|
||||
: '?';
|
||||
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
||||
_log.i('Actual quality: $actualQuality');
|
||||
}
|
||||
|
||||
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||
print('[DownloadQueue] Converting M4A to FLAC...');
|
||||
_log.d('Converting M4A to FLAC...');
|
||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||
if (flacPath != null) {
|
||||
filePath = flacPath;
|
||||
print('[DownloadQueue] Converted to: $flacPath');
|
||||
_log.d('Converted to: $flacPath');
|
||||
|
||||
// After conversion, embed metadata and cover to the new FLAC file
|
||||
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
|
||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||
try {
|
||||
await _embedMetadataAndCover(
|
||||
flacPath,
|
||||
item.track,
|
||||
);
|
||||
print('[DownloadQueue] Metadata and cover embedded successfully');
|
||||
_log.d('Metadata and cover embedded successfully');
|
||||
} catch (e) {
|
||||
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
|
||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check again if cancelled before updating status and adding to history
|
||||
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
||||
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled during finalization, cleaning up');
|
||||
// Delete the downloaded file
|
||||
if (filePath != null) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
_log.d('Deleted cancelled download file: $filePath');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to delete cancelled file: $e');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
progress: 1.0,
|
||||
filePath: filePath,
|
||||
);
|
||||
|
||||
// Increment completed counter
|
||||
_completedInSession++;
|
||||
|
||||
// Show completion notification for this track
|
||||
await _notificationService.showDownloadComplete(
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
completedCount: state.completedCount,
|
||||
completedCount: _completedInSession,
|
||||
totalCount: _totalQueuedAtStart,
|
||||
);
|
||||
|
||||
@@ -870,41 +1147,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
discNumber: item.track.discNumber,
|
||||
duration: item.track.duration,
|
||||
releaseDate: item.track.releaseDate,
|
||||
quality: state.audioQuality,
|
||||
quality: actualQuality,
|
||||
),
|
||||
);
|
||||
|
||||
// Auto-remove completed item from queue (it's now in history)
|
||||
removeItem(item.id);
|
||||
}
|
||||
} else {
|
||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||
print('[DownloadQueue] Download failed: $errorMsg');
|
||||
_log.e('Download failed: $errorMsg');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.failed,
|
||||
error: errorMsg,
|
||||
);
|
||||
_failedInSession++;
|
||||
}
|
||||
|
||||
// Increment download counter and cleanup connections periodically
|
||||
_downloadCount++;
|
||||
if (_downloadCount % _cleanupInterval == 0) {
|
||||
print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...');
|
||||
_log.d('Cleaning up idle connections (after $_downloadCount downloads)...');
|
||||
try {
|
||||
await PlatformBridge.cleanupConnections();
|
||||
} catch (e) {
|
||||
print('[DownloadQueue] Connection cleanup failed: $e');
|
||||
_log.e('Connection cleanup failed: $e');
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
if (state.concurrentDownloads == 1) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
print('[DownloadQueue] Exception: $e');
|
||||
print('[DownloadQueue] StackTrace: $stackTrace');
|
||||
_log.e('Exception: $e', e, stackTrace);
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.failed,
|
||||
error: e.toString(),
|
||||
);
|
||||
_failedInSession++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
|
||||
@@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
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()));
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
state = state.copyWith(defaultService: service);
|
||||
_saveSettings();
|
||||
@@ -76,6 +95,62 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
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>(
|
||||
|
||||
@@ -6,72 +6,147 @@ class TrackState {
|
||||
final List<Track> tracks;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final String? albumId;
|
||||
final String? albumName;
|
||||
final String? playlistName;
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
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({
|
||||
this.tracks = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.albumId,
|
||||
this.albumName,
|
||||
this.playlistName,
|
||||
this.artistId,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.artistAlbums,
|
||||
this.searchArtists,
|
||||
this.hasSearchText = false,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
String? albumId,
|
||||
String? albumName,
|
||||
String? playlistName,
|
||||
String? artistId,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
List<SearchArtist>? searchArtists,
|
||||
bool? hasSearchText,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
albumId: albumId ?? this.albumId,
|
||||
albumName: albumName ?? this.albumName,
|
||||
playlistName: playlistName ?? this.playlistName,
|
||||
artistId: artistId ?? this.artistId,
|
||||
artistName: artistName ?? this.artistName,
|
||||
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> {
|
||||
/// Request ID to track and cancel outdated requests
|
||||
int _currentRequestId = 0;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
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 {
|
||||
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 {
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
albumName: null,
|
||||
playlistName: null,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: parsed['id'] as String?,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
playlistName: null,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -79,34 +154,59 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: null,
|
||||
playlistName: owner?['name'] 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) {
|
||||
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 {
|
||||
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 {
|
||||
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 artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
|
||||
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,
|
||||
searchArtists: artists,
|
||||
isLoading: false,
|
||||
albumName: null,
|
||||
playlistName: null,
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
} 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();
|
||||
}
|
||||
|
||||
/// Set search text state for back button handling
|
||||
void setSearchText(bool hasText) {
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
@@ -183,6 +288,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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>(
|
||||
|
||||
@@ -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,
|
||||
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))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -177,9 +177,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
onDestinationSelected: _onNavTap,
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
icon: Icon(Icons.search_outlined),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
@@ -219,7 +219,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
placeholder: (_, _) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/queue_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 {
|
||||
const MainShell({super.key});
|
||||
@@ -19,6 +26,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
late PageController _pageController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress; // For double-tap to exit
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -27,9 +36,48 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
// 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;
|
||||
@@ -51,6 +99,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shareSubscription?.cancel();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -72,50 +121,123 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.download_outlined),
|
||||
return PopScope(
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) {
|
||||
// System handled the pop - this means predictive back completed
|
||||
// We need to handle double-tap to exit here
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.download),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
),
|
||||
label: 'Downloads',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
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,
|
||||
),
|
||||
);
|
||||
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:
|
||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||
case DownloadStatus.failed:
|
||||
|
||||
@@ -1,22 +1,110 @@
|
||||
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:open_filex/open_filex.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/screens/track_metadata_screen.dart';
|
||||
|
||||
class QueueTab extends ConsumerWidget {
|
||||
class QueueTab extends ConsumerStatefulWidget {
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
Widget build(BuildContext context) {
|
||||
// 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;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
expandedHeight: 130,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
@@ -24,12 +112,12 @@ class QueueTab extends ConsumerWidget {
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Downloads',
|
||||
'History',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
@@ -37,8 +125,8 @@ class QueueTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Pause/Resume controls when downloading
|
||||
if (queueState.isProcessing || queueState.queuedCount > 0)
|
||||
// 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),
|
||||
@@ -51,50 +139,34 @@ class QueueTab extends ConsumerWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: queueState.isPaused
|
||||
color: isPaused
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
queueState.isPaused ? Icons.pause : Icons.downloading,
|
||||
color: queueState.isPaused
|
||||
isPaused ? Icons.pause : Icons.downloading,
|
||||
color: isPaused
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Status text
|
||||
// Status text - simplified
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
queueState.isPaused ? 'Queue Paused' : 'Downloading...',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${queueState.completedCount}/${queueState.items.length} completed',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
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: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(queueState.isPaused ? Icons.play_arrow : Icons.pause, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text(queueState.isPaused ? 'Resume' : 'Pause'),
|
||||
],
|
||||
),
|
||||
child: Text(isPaused ? 'Resume' : 'Pause'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -103,170 +175,228 @@ class QueueTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Header with actions
|
||||
if (queueState.items.isNotEmpty)
|
||||
// Queue header
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${queueState.items.length} items',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Row(children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||
icon: const Icon(Icons.done_all, size: 18),
|
||||
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)),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
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
|
||||
if (queueState.items.isNotEmpty)
|
||||
// Queue list with keys for efficient updates
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
||||
childCount: queueState.items.length,
|
||||
(context, index) {
|
||||
final item = queueItems[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: _buildQueueItem(context, item, colorScheme),
|
||||
);
|
||||
},
|
||||
childCount: queueItems.length,
|
||||
)),
|
||||
|
||||
// Empty state or fill remaining for scroll
|
||||
if (queueState.items.isEmpty)
|
||||
// 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 SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.queue_music, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
Icon(Icons.history, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('No downloads in queue', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text('No download history', 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))),
|
||||
Text('Downloaded tracks will appear here', 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 _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||
final isCompleted = item.status == DownloadStatus.completed;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover art
|
||||
item.track.coverUrl != null
|
||||
? 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),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
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),
|
||||
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.error ?? 'Download failed',
|
||||
item.track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
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, ref, item, colorScheme),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Action buttons based on status
|
||||
_buildActionButtons(context, item, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
|
||||
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
||||
return item.track.coverUrl != null
|
||||
? 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),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
||||
switch (item.status) {
|
||||
case DownloadStatus.queued:
|
||||
// Queued: Show play (start) and cancel buttons
|
||||
// Queued: Show cancel button
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Cancel button
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||
icon: Icon(Icons.close, color: colorScheme.error),
|
||||
@@ -279,11 +409,10 @@ class QueueTab extends ConsumerWidget {
|
||||
);
|
||||
|
||||
case DownloadStatus.downloading:
|
||||
// Downloading: Show progress indicator and cancel button
|
||||
// Downloading: Show stop button
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Cancel button (skip this download)
|
||||
IconButton(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
|
||||
icon: Icon(Icons.stop, color: colorScheme.error),
|
||||
@@ -295,15 +424,52 @@ class QueueTab extends ConsumerWidget {
|
||||
],
|
||||
);
|
||||
|
||||
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:
|
||||
// Completed: Show check icon
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
// Completed: Show play button and check icon
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
return 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),
|
||||
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:
|
||||
@@ -355,16 +521,246 @@ class QueueTab extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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))),
|
||||
],
|
||||
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildHistoryGridItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToHistoryMetadataScreen(item),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Cover art with play button overlay
|
||||
Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Quality badge (top-left)
|
||||
if (item.quality != null && item.quality!.contains('bit'))
|
||||
Positioned(
|
||||
left: 4,
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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});
|
||||
@@ -21,7 +23,10 @@ class AboutPage extends StatelessWidget {
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
@@ -35,10 +40,13 @@ class AboutPage extends StatelessWidget {
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
// When collapsed (expandRatio=0): left=56 to align with back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('About',
|
||||
child: Text(
|
||||
'About',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -52,73 +60,90 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// App info card
|
||||
// App header card with logo and description
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
child: _AppHeaderCard(),
|
||||
),
|
||||
),
|
||||
|
||||
// GitHub section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('Original ${AppInfo.appName}'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
])),
|
||||
|
||||
// Credits section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
|
||||
// Contributors section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Contributors'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(children: [
|
||||
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
|
||||
const SizedBox(height: 12),
|
||||
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
|
||||
]),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -126,42 +151,214 @@ class AboutPage extends StatelessWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(child: Text(AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
class _CreditRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _CreditRow({required this.label, required this.value});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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});
|
||||
@@ -8,6 +10,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
@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;
|
||||
|
||||
@@ -37,7 +40,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Appearance',
|
||||
style: TextStyle(
|
||||
@@ -54,36 +57,52 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Theme section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _ThemeModeSelector(
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ThemeModeSelector(
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Color section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Color')),
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||
SliverToBoxAdapter(
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: const Text('Dynamic Color'),
|
||||
subtitle: const Text('Use colors from your wallpaper'),
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!themeSettings.useDynamicColor)
|
||||
SliverToBoxAdapter(
|
||||
child: _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()),
|
||||
@@ -93,17 +112,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ThemeModeSelector extends StatelessWidget {
|
||||
final ThemeMode currentMode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
@@ -111,21 +119,15 @@ class _ThemeModeSelector extends StatelessWidget {
|
||||
|
||||
@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: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
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)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,22 +142,40 @@ class _ThemeModeChip extends StatelessWidget {
|
||||
@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: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
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)),
|
||||
]),
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -177,9 +197,9 @@ class _ColorPicker extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
|
||||
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;
|
||||
@@ -201,3 +221,80 @@ class _ColorPicker extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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});
|
||||
@@ -38,7 +39,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Download',
|
||||
style: TextStyle(
|
||||
@@ -55,51 +56,82 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Service section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _ServiceSelector(
|
||||
currentService: settings.defaultService,
|
||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
||||
),
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ServiceSelector(
|
||||
currentService: settings.defaultService,
|
||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Quality section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Audio Quality')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', value: 'LOSSLESS',
|
||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS')),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', value: 'HI_RES',
|
||||
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', value: 'HI_RES_LOSSLESS',
|
||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS')),
|
||||
])),
|
||||
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
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'File Settings')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.text_fields, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Filename Format'),
|
||||
subtitle: Text(settings.filenameFormat),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.folder_outlined, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Download Directory'),
|
||||
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _pickDirectory(ref),
|
||||
),
|
||||
])),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
@@ -138,17 +170,73 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
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 {
|
||||
@@ -158,21 +246,15 @@ class _ServiceSelector extends StatelessWidget {
|
||||
|
||||
@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: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
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')),
|
||||
]),
|
||||
),
|
||||
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')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -187,9 +269,15 @@ class _ServiceChip extends StatelessWidget {
|
||||
@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 : Colors.transparent,
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -213,18 +301,75 @@ class _ServiceChip extends StatelessWidget {
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String value;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.value, required this.isSelected, required this.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),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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});
|
||||
@@ -37,7 +40,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Options',
|
||||
style: TextStyle(
|
||||
@@ -54,53 +57,111 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Download options section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Download')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.sync, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Auto Fallback'),
|
||||
subtitle: const Text('Try other services if download fails'),
|
||||
value: settings.autoFallback,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.lyrics, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Embed Lyrics'),
|
||||
subtitle: const Text('Embed synced lyrics into FLAC files'),
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.image, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Max Quality Cover'),
|
||||
subtitle: const Text('Download highest resolution cover art'),
|
||||
value: settings.maxQualityCover,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
|
||||
),
|
||||
])),
|
||||
),
|
||||
|
||||
// Performance section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Performance')),
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Performance')),
|
||||
SliverToBoxAdapter(
|
||||
child: _ConcurrentDownloadsSelector(
|
||||
currentValue: settings.concurrentDownloads,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ConcurrentDownloadsItem(
|
||||
currentValue: settings.concurrentDownloads,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// App section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'App')),
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
|
||||
SliverToBoxAdapter(
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(Icons.system_update, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Check for Updates'),
|
||||
subtitle: const Text('Notify when new version is available'),
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -109,37 +170,179 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConcurrentDownloadsSelector extends StatelessWidget {
|
||||
class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||
final int currentValue;
|
||||
final ValueChanged<int> onChanged;
|
||||
const _ConcurrentDownloadsSelector({required this.currentValue, required this.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.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
|
||||
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Concurrent Downloads'),
|
||||
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.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
@@ -171,9 +374,15 @@ class _ConcurrentChip extends StatelessWidget {
|
||||
@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 : colorScheme.surfaceContainerHighest,
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
const SettingsTab({super.key});
|
||||
@@ -15,9 +16,9 @@ class SettingsTab extends ConsumerWidget {
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
// Collapsing App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
expandedHeight: 130,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
@@ -25,12 +26,12 @@ class SettingsTab extends ConsumerWidget {
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
@@ -38,35 +39,50 @@ class SettingsTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Menu items
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Appearance',
|
||||
subtitle: 'Theme, colors, display',
|
||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.download_outlined,
|
||||
title: 'Download',
|
||||
subtitle: 'Service, quality, filename format',
|
||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||
),
|
||||
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: 'Options',
|
||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
_SettingsMenuItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
||||
onTap: () => _navigateTo(context, const AboutPage()),
|
||||
),
|
||||
])),
|
||||
),
|
||||
|
||||
// Fill remaining space to enable scroll
|
||||
// Fill remaining space
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
);
|
||||
@@ -76,37 +92,3 @@ class SettingsTab extends ConsumerWidget {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsMenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SettingsMenuItem({required this.icon, required this.title, required this.subtitle, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 22),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
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),
|
||||
Text(AppInfo.appName),
|
||||
],
|
||||
@@ -392,7 +392,19 @@ class SettingsScreen extends ConsumerWidget {
|
||||
title: const Text('Select Quality'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
||||
],
|
||||
|
||||
@@ -203,7 +203,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
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),
|
||||
Text(AppInfo.appName),
|
||||
],
|
||||
@@ -389,7 +389,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
title: const Text('Select Quality'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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, '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),
|
||||
|
||||
@@ -87,10 +87,43 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
PermissionStatus status;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
// Android 13+: Use audio permission
|
||||
status = await Permission.audio.request();
|
||||
} 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 {
|
||||
// Android 10 and below: Use legacy storage permission
|
||||
status = await Permission.storage.request();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ 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:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
/// Screen to display detailed metadata for a downloaded track
|
||||
/// Designed with Material Expressive 3 style
|
||||
@@ -21,6 +23,9 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _fileExists = false;
|
||||
int? _fileSize;
|
||||
String? _lyrics;
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -42,6 +47,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_fileExists = exists;
|
||||
_fileSize = size;
|
||||
});
|
||||
|
||||
// Auto-load lyrics if file exists (embedded lyrics are instant)
|
||||
if (exists) {
|
||||
_fetchLyrics();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +123,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
// File info card
|
||||
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Lyrics card
|
||||
_buildLyricsCard(context, colorScheme),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
@@ -182,7 +197,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
placeholder: (_, _) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
@@ -349,22 +364,38 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
||||
if (item.spotifyId == null) return;
|
||||
|
||||
final url = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||
|
||||
try {
|
||||
// Try to open in Spotify app first, fallback to browser
|
||||
final uri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||
// ignore: deprecated_member_use
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
// Try to open in Spotify app first using URI scheme
|
||||
final launched = await launchUrl(
|
||||
spotifyUri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
if (!launched) {
|
||||
// Fallback to web URL which will redirect to app if installed
|
||||
await launchUrl(
|
||||
Uri.parse(webUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, url);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
||||
// If URI scheme fails, try web URL
|
||||
try {
|
||||
await launchUrl(
|
||||
Uri.parse(webUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} catch (_) {
|
||||
// Last resort: copy to clipboard
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, webUrl);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,6 +413,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_MetadataItem('Disc number', item.discNumber.toString()),
|
||||
if (item.duration != null)
|
||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||
if (item.quality != null && item.quality!.contains('bit'))
|
||||
_MetadataItem('Audio quality', item.quality!),
|
||||
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
||||
_MetadataItem('Release date', item.releaseDate!),
|
||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
||||
@@ -623,6 +656,157 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lyrics_outlined,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Lyrics',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_lyrics != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
onPressed: () => _copyToClipboard(context, _lyrics!),
|
||||
tooltip: 'Copy lyrics',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (_lyricsLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_lyricsError != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_lyricsError!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _fetchLyrics,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_lyrics != null)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
_lyrics!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Center(
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _fetchLyrics,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Load Lyrics'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchLyrics() async {
|
||||
if (_lyricsLoading) return;
|
||||
|
||||
setState(() {
|
||||
_lyricsLoading = true;
|
||||
_lyricsError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getLyricsLRC(
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (result.isEmpty) {
|
||||
setState(() {
|
||||
_lyricsError = 'Lyrics not found';
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
} else {
|
||||
// Clean up LRC timestamps for display
|
||||
final cleanLyrics = _cleanLrcForDisplay(result);
|
||||
setState(() {
|
||||
_lyrics = cleanLyrics;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_lyricsError = 'Failed to load lyrics';
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _cleanLrcForDisplay(String lrc) {
|
||||
// Remove LRC timestamps [mm:ss.xx] for cleaner display
|
||||
final lines = lrc.split('\n');
|
||||
final cleanLines = <String>[];
|
||||
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
||||
|
||||
for (final line in lines) {
|
||||
final cleanLine = line.replaceAll(timestampPattern, '').trim();
|
||||
if (cleanLine.isNotEmpty) {
|
||||
cleanLines.add(cleanLine);
|
||||
}
|
||||
}
|
||||
|
||||
return cleanLines.join('\n');
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -695,7 +879,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
title: const Text('Share'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: Implement share
|
||||
_shareFile(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -767,6 +951,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareFile(BuildContext context) async {
|
||||
final file = File(item.filePath);
|
||||
if (!await file.exists()) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('File not found')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(item.filePath)],
|
||||
text: '${item.trackName} - ${item.artistName}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFullDate(DateTime date) {
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
@@ -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 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
/// FFmpeg service for audio conversion and remuxing
|
||||
class FFmpegService {
|
||||
@@ -27,7 +30,7 @@ class FFmpegService {
|
||||
// Log error for debugging
|
||||
final logs = await session.getLogs();
|
||||
for (final log in logs) {
|
||||
print('[FFmpeg] ${log.getMessage()}');
|
||||
_log.d(log.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -10,6 +10,7 @@ class NotificationService {
|
||||
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';
|
||||
@@ -97,6 +98,49 @@ class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -182,4 +226,121 @@ class NotificationService {
|
||||
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>;
|
||||
}
|
||||
|
||||
/// 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
|
||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
@@ -200,15 +210,18 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
/// Get lyrics in LRC format
|
||||
/// First tries to extract from embedded file, then falls back to internet
|
||||
static Future<String> getLyricsLRC(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
String artistName,
|
||||
) async {
|
||||
String artistName, {
|
||||
String? filePath,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getLyricsLRC', {
|
||||
'spotify_id': spotifyId,
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'file_path': filePath ?? '',
|
||||
});
|
||||
return result as String;
|
||||
}
|
||||
@@ -230,4 +243,54 @@ class PlatformBridge {
|
||||
static Future<void> cleanupConnections() async {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -19,7 +25,36 @@ class UpdateInfo {
|
||||
class UpdateChecker {
|
||||
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
|
||||
/// Check for updates from GitHub releases
|
||||
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(
|
||||
@@ -28,7 +63,7 @@ class UpdateChecker {
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('[UpdateChecker] GitHub API returned ${response.statusCode}');
|
||||
_log.w('GitHub API returned ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -37,36 +72,74 @@ class UpdateChecker {
|
||||
final latestVersion = tagName.replaceFirst('v', '');
|
||||
|
||||
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
||||
print('[UpdateChecker] No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
||||
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get changelog from release body
|
||||
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();
|
||||
|
||||
print('[UpdateChecker] Update available: $latestVersion');
|
||||
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) {
|
||||
print('[UpdateChecker] Error checking for updates: $e');
|
||||
_log.e('Error checking for updates: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare version strings (e.g., "1.1.1" vs "1.1.0")
|
||||
static bool _isNewerVersion(String latest, String current) {
|
||||
try {
|
||||
final latestParts = latest.split('.').map(int.parse).toList();
|
||||
final currentParts = current.split('.').map(int.parse).toList();
|
||||
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();
|
||||
|
||||
// Pad with zeros if needed
|
||||
while (latestParts.length < 3) {
|
||||
latestParts.add(0);
|
||||
}
|
||||
@@ -78,8 +151,15 @@ class UpdateChecker {
|
||||
if (latestParts[i] > currentParts[i]) return true;
|
||||
if (latestParts[i] < currentParts[i]) return false;
|
||||
}
|
||||
return false; // Same version
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
// Use dynamic colors from wallpaper (Android 12+)
|
||||
lightScheme = lightDynamic;
|
||||
darkScheme = darkDynamic;
|
||||
debugPrint('Using dynamic color from wallpaper');
|
||||
} else {
|
||||
// Fallback to seed color
|
||||
final seedColor = themeSettings.seedColor;
|
||||
@@ -39,7 +38,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}');
|
||||
}
|
||||
|
||||
// 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,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ 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 StatelessWidget {
|
||||
class UpdateDialog extends StatefulWidget {
|
||||
final UpdateInfo updateInfo;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onDisableUpdates;
|
||||
@@ -15,121 +17,289 @@ class UpdateDialog extends StatelessWidget {
|
||||
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 AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.system_update, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Update Available'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
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: [
|
||||
// Version info
|
||||
// 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.all(12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
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: [
|
||||
Text(
|
||||
'v${AppInfo.version}',
|
||||
style: TextStyle(color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'v${updateInfo.version}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
_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: 16),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Changelog header
|
||||
Text(
|
||||
'What\'s New:',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Changelog content (scrollable)
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
// Download progress (when downloading)
|
||||
if (_isDownloading) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
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(12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_formatChangelog(updateInfo.changelog),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
_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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Don't remind again button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDisableUpdates();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
'Don\'t remind',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
// Later button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Later'),
|
||||
),
|
||||
// Download button
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final uri = Uri.parse(updateInfo.downloadUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Format changelog - clean up markdown and extract relevant content
|
||||
String _formatChangelog(String changelog) {
|
||||
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
|
||||
var content = changelog;
|
||||
|
||||
// Find content after "What's New" header
|
||||
@@ -147,19 +317,18 @@ class UpdateDialog extends StatelessWidget {
|
||||
// Process line by line for better formatting
|
||||
final lines = content.split('\n');
|
||||
final formattedLines = <String>[];
|
||||
String? currentSection;
|
||||
|
||||
for (var line in lines) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
// Check if it's a section header (### Added, ### Fixed, etc.)
|
||||
// Check if it's a section header
|
||||
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||
if (sectionMatch != null) {
|
||||
currentSection = sectionMatch.group(1)?.trim();
|
||||
if (currentSection != null && currentSection.isNotEmpty) {
|
||||
final section = sectionMatch.group(1)?.trim();
|
||||
if (section != null && section.isNotEmpty) {
|
||||
if (formattedLines.isNotEmpty) formattedLines.add('');
|
||||
formattedLines.add('$currentSection:');
|
||||
formattedLines.add(section);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -168,36 +337,23 @@ class UpdateDialog extends StatelessWidget {
|
||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||
if (listMatch != null) {
|
||||
var itemText = listMatch.group(1) ?? '';
|
||||
// Remove bold markdown
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'\*\*([^*]+)\*\*'),
|
||||
(m) => m.group(1) ?? ''
|
||||
);
|
||||
// Remove code markdown
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'`([^`]+)`'),
|
||||
(m) => m.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 (indented list)
|
||||
// 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) ?? ''
|
||||
);
|
||||
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||
formattedLines.add(' - $itemText');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var formatted = formattedLines.join('\n').trim();
|
||||
|
||||
// Limit length
|
||||
if (formatted.length > 2000) {
|
||||
formatted = '${formatted.substring(0, 2000)}...';
|
||||
}
|
||||
@@ -206,6 +362,44 @@ class UpdateDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
|
||||
@@ -5,26 +5,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "85.0.0"
|
||||
version: "91.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
||||
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.6.0"
|
||||
version: "8.4.1"
|
||||
analyzer_buffer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_buffer
|
||||
sha256: f7833bee67c03c37241c67f8741b17cc501b69d9758df7a5a4a13ed6c947be43
|
||||
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.10"
|
||||
version: "0.1.11"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -61,18 +61,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
|
||||
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "4.0.3"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "1.2.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,30 +81,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
|
||||
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.1"
|
||||
version: "2.10.4"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -245,10 +229,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
version: "3.1.3"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -386,26 +370,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
version: "19.5.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -576,6 +568,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
logger:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logger
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -620,10 +620,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mockito
|
||||
sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99"
|
||||
sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.5.0"
|
||||
version: "5.6.1"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -824,6 +824,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -868,18 +876,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.4"
|
||||
version: "12.0.1"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
version: "6.1.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -977,18 +985,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3"
|
||||
sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "4.1.1"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.7"
|
||||
version: "1.3.8"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1141,14 +1149,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 1.5.0+14
|
||||
version: 2.0.4+34
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -46,20 +46,22 @@ dependencies:
|
||||
# Utils
|
||||
url_launcher: ^6.3.1
|
||||
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_kit_flutter_new_audio: ^2.0.0
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: ^18.0.1
|
||||
flutter_local_notifications: ^19.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
build_runner: ^2.4.15
|
||||
build_runner: ^2.10.4
|
||||
riverpod_generator: ^4.0.0
|
||||
json_serializable: ^6.11.2
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.0] - 2026-01-01
|
||||
|
||||
### Added
|
||||
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
||||
- Default: Sequential (1 at a time) for stability
|
||||
- Options: 1, 2, or 3 concurrent downloads
|
||||
- Warning about potential rate limiting from streaming services
|
||||
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
|
||||
- **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 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
|
||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
||||
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
||||
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
||||
|
||||
### Changed
|
||||
- Updated version to 1.1.0
|
||||
|
||||
### Technical Details
|
||||
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
||||
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
||||
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
||||
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
|
||||
- Added shared `http.Transport` with connection pooling in `httputil.go`
|
||||
- Added `CleanupConnections()` export for Flutter to call via method channel
|
||||
|
||||
## [1.0.5] - Previous Release
|
||||
- Material Expressive 3 UI
|
||||
- Dynamic color support
|
||||
- Swipe navigation with PageView
|
||||
- Settings as bottom navigation tab
|
||||
- APK size optimization
|
||||