Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
@@ -86,16 +86,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
|
||||
|
||||
@@ -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,142 @@
|
||||
# Changelog
|
||||
|
||||
## [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 +157,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
|
||||
|
||||
@@ -26,10 +26,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
## 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" width="200" />
|
||||
<img src="assets/images/2.jpg" width="200" />
|
||||
<img src="assets/images/3.jpg" width="200" />
|
||||
<img src="assets/images/4.jpg" width="200" />
|
||||
</p>
|
||||
|
||||
## Other project
|
||||
@@ -39,6 +39,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(
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<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"
|
||||
@@ -22,8 +24,7 @@
|
||||
<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 +77,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)
|
||||
|
||||
@@ -160,6 +167,29 @@ 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())
|
||||
}
|
||||
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: 86 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 163 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 |
|
After Width: | Height: | Size: 130 KiB |
@@ -364,6 +364,17 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
fmt.Println("[Amazon] No lyrics found for this track")
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
|
||||
// Convert Japanese lyrics to romaji if enabled
|
||||
if req.ConvertLyricsToRomaji {
|
||||
for i := range lyrics.Lines {
|
||||
if ContainsKana(lyrics.Lines[i].Words) {
|
||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
||||
}
|
||||
}
|
||||
fmt.Println("[Amazon] Converted Japanese lyrics to romaji")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
|
||||
@@ -102,6 +102,7 @@ type DownloadRequest struct {
|
||||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
ConvertLyricsToRomaji bool `json:"convert_lyrics_to_romaji"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
@@ -373,6 +374,12 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ConvertToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
||||
// Kanji characters are preserved as-is
|
||||
func ConvertToRomaji(text string) string {
|
||||
return ToRomaji(text)
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
|
||||
@@ -267,5 +267,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
}
|
||||
pw.current += int64(n)
|
||||
SetItemBytesReceived(pw.itemID, pw.current)
|
||||
// Also update legacy progress for backward compatibility
|
||||
SetBytesReceived(pw.current)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -426,6 +426,17 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
fmt.Println("[Qobuz] No lyrics found for this track")
|
||||
} else {
|
||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
|
||||
// Convert Japanese lyrics to romaji if enabled
|
||||
if req.ConvertLyricsToRomaji {
|
||||
for i := range lyrics.Lines {
|
||||
if ContainsKana(lyrics.Lines[i].Words) {
|
||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
||||
}
|
||||
}
|
||||
fmt.Println("[Qobuz] Converted Japanese lyrics to romaji")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
|
||||
@@ -20,6 +20,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -131,6 +133,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"`
|
||||
@@ -212,6 +240,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)
|
||||
}
|
||||
@@ -405,6 +435,88 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
return &ArtistResponsePayload{
|
||||
ArtistInfo: artistInfo,
|
||||
Albums: albums,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||
var data struct {
|
||||
ExternalID externalID `json:"external_ids"`
|
||||
|
||||
@@ -961,6 +961,17 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
fmt.Println("[Tidal] No lyrics found for this track")
|
||||
} else {
|
||||
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
|
||||
// Convert Japanese lyrics to romaji if enabled
|
||||
if req.ConvertLyricsToRomaji {
|
||||
for i := range lyrics.Lines {
|
||||
if ContainsKana(lyrics.Lines[i].Words) {
|
||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
||||
}
|
||||
}
|
||||
fmt.Println("[Tidal] Converted Japanese lyrics to romaji")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '1.5.0';
|
||||
static const String buildNumber = '14';
|
||||
static const String version = '1.6.1';
|
||||
static const String buildNumber = '26';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static const String appName = '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(
|
||||
|
||||
@@ -22,6 +22,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 +33,7 @@ class DownloadItem {
|
||||
this.filePath,
|
||||
this.error,
|
||||
required this.createdAt,
|
||||
this.qualityOverride,
|
||||
});
|
||||
|
||||
DownloadItem copyWith({
|
||||
@@ -43,6 +45,7 @@ class DownloadItem {
|
||||
String? filePath,
|
||||
String? error,
|
||||
DateTime? createdAt,
|
||||
String? qualityOverride,
|
||||
}) {
|
||||
return DownloadItem(
|
||||
id: id ?? this.id,
|
||||
@@ -53,6 +56,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,6 +30,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
'filePath': instance.filePath,
|
||||
'error': instance.error,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'qualityOverride': instance.qualityOverride,
|
||||
};
|
||||
|
||||
const _$DownloadStatusEnumMap = {
|
||||
|
||||
@@ -14,6 +14,11 @@ 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 bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji
|
||||
final String historyViewMode; // list, grid
|
||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -26,6 +31,11 @@ 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.convertLyricsToRomaji = false, // Default: keep original Japanese
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.askQualityBeforeDownload = false, // Default: use preset quality
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -39,6 +49,11 @@ class AppSettings {
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
bool? checkForUpdates,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? convertLyricsToRomaji,
|
||||
String? historyViewMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -51,6 +66,11 @@ class AppSettings {
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ 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',
|
||||
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -31,4 +36,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
@@ -132,12 +136,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 +150,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,18 +242,90 @@ 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
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
// Initialize output directory asynchronously
|
||||
// Initialize output directory and load persisted queue asynchronously
|
||||
Future.microtask(() async {
|
||||
await _initOutputDir();
|
||||
await _loadQueueFromStorage();
|
||||
});
|
||||
return const DownloadQueueState();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
// 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');
|
||||
|
||||
// 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);
|
||||
}
|
||||
} else {
|
||||
_log.d('No queue found in storage');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to load queue from storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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');
|
||||
}
|
||||
}
|
||||
|
||||
void _startProgressPolling(String itemId) {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||
@@ -272,12 +348,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
);
|
||||
|
||||
// Update foreground service notification (Android)
|
||||
if (Platform.isAndroid) {
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
queueCount: state.queuedCount,
|
||||
).catchError((_) {}); // Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// 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)');
|
||||
_log.d('Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore polling errors
|
||||
@@ -307,7 +394,7 @@ 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)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +414,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +484,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 +540,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 +551,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 +565,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 +577,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 +604,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 +629,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 +642,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 +650,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 +676,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 +688,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 +707,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 +734,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 +751,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 +759,38 @@ 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;
|
||||
|
||||
// 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 +799,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,13 +811,23 @@ 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;
|
||||
}
|
||||
@@ -655,7 +842,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Queue processing finished');
|
||||
_log.i('Queue processing finished');
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
}
|
||||
|
||||
@@ -664,7 +851,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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,7 +867,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (nextItem.id.isEmpty) {
|
||||
print('[DownloadQueue] No more items to process');
|
||||
_log.d('No more items to process');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -699,7 +886,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +899,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 +918,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
|
||||
@@ -748,8 +935,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
/// 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) {
|
||||
@@ -760,11 +947,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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,14 +968,15 @@ 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,
|
||||
preferredService: item.service,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.downloadTrack(
|
||||
@@ -792,13 +988,14 @@ 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
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -807,31 +1004,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Result: $result');
|
||||
_log.d('Result: $result');
|
||||
|
||||
if (result['success'] == true) {
|
||||
var filePath = result['file_path'] as String?;
|
||||
print('[DownloadQueue] Download success, file: $filePath');
|
||||
_log.i('Download success, file: $filePath');
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -870,13 +1067,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
discNumber: item.track.discNumber,
|
||||
duration: item.track.duration,
|
||||
releaseDate: item.track.releaseDate,
|
||||
quality: state.audioQuality,
|
||||
quality: quality,
|
||||
),
|
||||
);
|
||||
|
||||
// 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,
|
||||
@@ -887,19 +1087,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// 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,
|
||||
|
||||
@@ -76,6 +76,33 @@ 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 setConvertLyricsToRomaji(bool enabled) {
|
||||
state = state.copyWith(convertLyricsToRomaji: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAskQualityBeforeDownload(bool enabled) {
|
||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -8,7 +8,11 @@ class TrackState {
|
||||
final String? error;
|
||||
final String? albumName;
|
||||
final String? playlistName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final TrackState? previousState; // For back navigation
|
||||
final bool hasSearchText; // For back button handling
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -16,16 +20,29 @@ class TrackState {
|
||||
this.error,
|
||||
this.albumName,
|
||||
this.playlistName,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.artistAlbums,
|
||||
this.previousState,
|
||||
this.hasSearchText = false,
|
||||
});
|
||||
|
||||
bool get canGoBack => previousState != null;
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
String? albumName,
|
||||
String? playlistName,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
TrackState? previousState,
|
||||
bool clearPreviousState = false,
|
||||
bool? hasSearchText,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -33,11 +50,36 @@ class TrackState {
|
||||
error: error,
|
||||
albumName: albumName ?? this.albumName,
|
||||
playlistName: playlistName ?? this.playlistName,
|
||||
artistName: artistName ?? this.artistName,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
@override
|
||||
TrackState build() {
|
||||
@@ -45,7 +87,18 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
Future<void> fetchFromUrl(String url) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
// Save current state for back navigation (only if we have content or it's empty)
|
||||
final savedState = state.hasContent ? TrackState(
|
||||
tracks: state.tracks,
|
||||
albumName: state.albumName,
|
||||
playlistName: state.playlistName,
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState,
|
||||
) : const TrackState(); // Empty state for back to home
|
||||
|
||||
state = TrackState(isLoading: true, previousState: savedState);
|
||||
|
||||
try {
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
@@ -56,57 +109,78 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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,
|
||||
previousState: savedState,
|
||||
);
|
||||
} 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,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
playlistName: null,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_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();
|
||||
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?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} 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,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
previousState: savedState,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
// Save current state for back navigation
|
||||
final savedState = state.hasContent ? TrackState(
|
||||
tracks: state.tracks,
|
||||
albumName: state.albumName,
|
||||
playlistName: state.playlistName,
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState,
|
||||
) : const TrackState();
|
||||
|
||||
state = TrackState(isLoading: true, previousState: savedState);
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: null,
|
||||
playlistName: null,
|
||||
previousState: savedState,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +226,59 @@ 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);
|
||||
}
|
||||
|
||||
/// Go back to previous state (if available)
|
||||
bool goBack() {
|
||||
if (state.previousState != null) {
|
||||
state = state.previousState!;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Fetch album from artist view - saves current artist state for back navigation
|
||||
Future<void> fetchAlbumFromArtist(String albumId) async {
|
||||
// Save current artist state before fetching album
|
||||
final savedState = TrackState(
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState, // Keep the chain
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
previousState: savedState,
|
||||
);
|
||||
|
||||
try {
|
||||
final url = 'https://open.spotify.com/album/$albumId';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
|
||||
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 = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} catch (e) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
previousState: savedState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
@@ -183,6 +310,18 @@ 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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||
|
||||
@@ -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,9 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
@@ -15,40 +14,104 @@ class HomeTab extends ConsumerStatefulWidget {
|
||||
ConsumerState<HomeTab> createState() => _HomeTabState();
|
||||
}
|
||||
|
||||
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin {
|
||||
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||
final _urlController = TextEditingController();
|
||||
final Map<String, bool> _fileExistsCache = {}; // Cache file existence
|
||||
Timer? _debounce;
|
||||
bool _isTyping = false;
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void dispose() { _urlController.dispose(); super.dispose(); }
|
||||
void initState() {
|
||||
super.initState();
|
||||
_urlController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_urlController.removeListener(_onSearchChanged);
|
||||
_urlController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Check if file exists with caching to avoid blocking main thread
|
||||
bool _checkFileExists(String filePath) {
|
||||
if (_fileExistsCache.containsKey(filePath)) {
|
||||
return _fileExistsCache[filePath]!;
|
||||
/// Called when trackState changes - used to sync search bar with state
|
||||
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
||||
// If state was cleared (no content, no search text, not loading), clear the search bar
|
||||
if (previous != null &&
|
||||
!next.hasContent &&
|
||||
!next.hasSearchText &&
|
||||
!next.isLoading &&
|
||||
_urlController.text.isNotEmpty) {
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
}
|
||||
// Schedule async check and return false for now
|
||||
Future.microtask(() async {
|
||||
final exists = await File(filePath).exists();
|
||||
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||
setState(() => _fileExistsCache[filePath] = exists);
|
||||
} void _onSearchChanged() {
|
||||
final text = _urlController.text.trim();
|
||||
final wasFocused = _searchFocusNode.hasFocus;
|
||||
|
||||
// Update search text state for MainShell back button handling
|
||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||
|
||||
// Update typing state immediately for UI transition
|
||||
if (text.isNotEmpty && !_isTyping) {
|
||||
setState(() => _isTyping = true);
|
||||
} else if (text.isEmpty && _isTyping) {
|
||||
setState(() => _isTyping = false);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-request focus after rebuild if it was focused
|
||||
if (wasFocused) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_searchFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Don't live search for URLs - wait for submit
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
||||
_debounce?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search queries
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||||
if (text.length >= 2) {
|
||||
_performSearch(text);
|
||||
}
|
||||
});
|
||||
_fileExistsCache[filePath] = false; // Assume false until checked
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
await ref.read(trackProvider.notifier).search(query);
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data?.text != null) _urlController.text = data!.text!;
|
||||
if (data?.text != null) {
|
||||
_urlController.text = data!.text!;
|
||||
// For URLs, trigger fetch immediately after paste
|
||||
final text = data.text!.trim();
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
||||
_fetchMetadata();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAndRefresh() async {
|
||||
_debounce?.cancel();
|
||||
_urlController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
setState(() => _isTyping = false);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
Future<void> _fetchMetadata() async {
|
||||
@@ -59,6 +122,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
} else {
|
||||
await ref.read(trackProvider.notifier).search(url);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
void _downloadTrack(int index) {
|
||||
@@ -66,8 +130,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (index >= 0 && index < trackState.tracks.length) {
|
||||
final track = trackState.tracks[index];
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
|
||||
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')));
|
||||
});
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,193 +147,357 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final trackState = ref.read(trackProvider);
|
||||
if (trackState.tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
|
||||
});
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
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 _showQualityPicker(BuildContext context, void Function(String quality) onSelect) {
|
||||
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('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'FLAC Lossless',
|
||||
subtitle: '16-bit / 44.1kHz',
|
||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC',
|
||||
subtitle: '24-bit / up to 96kHz',
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC Max',
|
||||
subtitle: '24-bit / up to 192kHz',
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _hasResults {
|
||||
final trackState = ref.watch(trackProvider);
|
||||
// Show results view when typing, loading, or has results
|
||||
return _isTyping || trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
// Listen for state changes to sync search bar
|
||||
ref.listen<TrackState>(trackProvider, _onTrackStateChanged);
|
||||
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final historyState = ref.watch(downloadHistoryProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasResults = _hasResults;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final historyItems = ref.watch(downloadHistoryProvider).items;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _clearAndRefresh,
|
||||
displacement: 100,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 100,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.4,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Home',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
// App Bar - always present
|
||||
SliverAppBar(
|
||||
expandedHeight: 130,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Search',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search bar - Simple TextField with border
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: TextField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: _pasteFromClipboard,
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
|
||||
// Idle content (logo, title) - always in tree, animated size
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
child: hasResults
|
||||
? const SizedBox.shrink()
|
||||
: Column(
|
||||
children: [
|
||||
SizedBox(height: screenHeight * 0.06),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20),
|
||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
||||
),
|
||||
onPressed: _fetchMetadata,
|
||||
tooltip: 'Search',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search Music',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paste a Spotify link or search by name',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
),
|
||||
onSubmitted: (_) => _fetchMetadata(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Helper text
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Text(
|
||||
'Supports: Track, Album, Playlist URLs',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
// Search bar - always present at same position in tree
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16),
|
||||
child: _buildSearchBar(colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (trackState.error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
|
||||
)),
|
||||
|
||||
// Loading indicator
|
||||
if (trackState.isLoading)
|
||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||
|
||||
// Album/Playlist header
|
||||
if (trackState.albumName != null || trackState.playlistName != null)
|
||||
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
|
||||
|
||||
// Download All button
|
||||
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${trackState.tracks.length})'),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
|
||||
)),
|
||||
|
||||
// Track list
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackTile(index, colorScheme),
|
||||
childCount: trackState.tracks.length,
|
||||
)),
|
||||
|
||||
// Divider
|
||||
if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty)
|
||||
const SliverToBoxAdapter(child: Divider(height: 32)),
|
||||
|
||||
// Recent Downloads header
|
||||
if (historyState.items.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Recent Downloads', style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
TextButton(onPressed: () => _showClearHistoryDialog(colorScheme), child: const Text('Clear')),
|
||||
],
|
||||
|
||||
// Idle content below search bar - always in tree
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
child: hasResults
|
||||
? const SizedBox.shrink()
|
||||
: Column(
|
||||
children: [
|
||||
if (!ref.watch(settingsProvider).hasSearchedBefore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Supports: Track, Album, Playlist, Artist URLs',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (historyItems.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
|
||||
child: _buildRecentDownloads(historyItems, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
|
||||
// Recent Downloads list
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length > 10 ? 10 : historyState.items.length,
|
||||
)),
|
||||
|
||||
// Show more button
|
||||
if (historyState.items.length > 10)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: OutlinedButton(onPressed: () => _showAllHistory(colorScheme),
|
||||
child: Text('Show all ${historyState.items.length} downloads')),
|
||||
)),
|
||||
|
||||
// Empty state or fill remaining for scroll
|
||||
if (trackState.tracks.isEmpty && historyState.items.isEmpty)
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(colorScheme))
|
||||
else
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
),
|
||||
|
||||
// Results content - always in tree
|
||||
..._buildResultsContent(trackState, colorScheme, hasResults),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecentDownloads(List<DownloadHistoryItem> items, ColorScheme colorScheme) {
|
||||
final displayItems = items.take(10).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Recent',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: displayItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = displayItems[index];
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToMetadataScreen(item),
|
||||
child: Container(
|
||||
width: 60,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: item.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.trackName,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
// Results content slivers (without app bar and search bar)
|
||||
List<Widget> _buildResultsContent(TrackState trackState, ColorScheme colorScheme, bool hasResults) {
|
||||
// Return empty slivers when no results to keep tree structure stable
|
||||
if (!hasResults) {
|
||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||
}
|
||||
|
||||
return [
|
||||
// Error message
|
||||
if (trackState.error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
|
||||
)),
|
||||
|
||||
// Loading indicator
|
||||
if (trackState.isLoading)
|
||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||
|
||||
// Album/Playlist header
|
||||
if (trackState.albumName != null || trackState.playlistName != null)
|
||||
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
|
||||
|
||||
// Artist header and discography
|
||||
if (trackState.artistName != null && trackState.artistAlbums != null)
|
||||
SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)),
|
||||
|
||||
if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)),
|
||||
|
||||
// Download All button
|
||||
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null && trackState.artistAlbums == null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${trackState.tracks.length})'),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
|
||||
)),
|
||||
|
||||
// Track list
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackTile(index, colorScheme),
|
||||
childCount: trackState.tracks.length,
|
||||
)),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||
final hasText = _urlController.text.isNotEmpty;
|
||||
|
||||
return TextField(
|
||||
controller: _urlController,
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.5)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.5)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasText)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: _clearAndRefresh,
|
||||
tooltip: 'Clear',
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: _pasteFromClipboard,
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
),
|
||||
onSubmitted: (_) => _fetchMetadata(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
@@ -272,7 +508,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (state.coverUrl != null)
|
||||
ClipRRect(borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))),
|
||||
placeholder: (_, _) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(state.albumName ?? state.playlistName ?? '',
|
||||
@@ -291,6 +527,154 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistHeader(TrackState state, ColorScheme colorScheme) {
|
||||
final albumCount = state.artistAlbums?.length ?? 0;
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.coverUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: state.coverUrl!,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.person, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.artistName ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$albumCount releases',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistDiscography(TrackState state, ColorScheme colorScheme) {
|
||||
final albums = state.artistAlbums ?? [];
|
||||
|
||||
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 Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (albumsOnly.isNotEmpty) _buildAlbumSection('Albums', albumsOnly, colorScheme),
|
||||
if (singles.isNotEmpty) _buildAlbumSection('Singles & EPs', singles, colorScheme),
|
||||
if (compilations.isNotEmpty) _buildAlbumSection('Compilations', compilations, colorScheme),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'$title (${albums.length})',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) => _buildAlbumCard(albums[index], colorScheme),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||
return GestureDetector(
|
||||
onTap: () => _fetchAlbum(album.id),
|
||||
child: Container(
|
||||
width: 130,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
width: 130,
|
||||
height: 130,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 260,
|
||||
memCacheHeight: 260,
|
||||
)
|
||||
: Container(
|
||||
width: 130,
|
||||
height: 130,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.name,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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 _fetchAlbum(String albumId) {
|
||||
// Use fetchAlbumFromArtist to save artist state for back navigation
|
||||
ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId);
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||
final track = ref.watch(trackProvider).tracks[index];
|
||||
return ListTile(
|
||||
@@ -313,132 +697,23 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
onTap: () => _downloadTrack(index),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
class _QualityPickerOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
const _QualityPickerOption({required this.title, required this.subtitle, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
leading: Hero(tag: 'cover_${item.id}',
|
||||
child: item.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 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(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: fileExists
|
||||
? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
|
||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
onTap: () => _navigateToMetadataScreen(item),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) => Center(child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withOpacity(0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Ready to Download',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paste a Spotify link in the search bar above to get started',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(ColorScheme colorScheme) {
|
||||
showDialog(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text('Clear all download history?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); },
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
void _showAllHistory(ColorScheme colorScheme) {
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
showModalBottomSheet(context: context, isScrollControlled: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7, minChildSize: 0.5, maxChildSize: 0.95, expand: false,
|
||||
builder: (context, scrollController) => Column(children: [
|
||||
Padding(padding: const EdgeInsets.all(16), child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('All Downloads (${historyState.items.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
)),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: historyState.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = historyState.items[index];
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
return ListTile(
|
||||
leading: item.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 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(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: fileExists
|
||||
? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
|
||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
onTap: () { Navigator.pop(context); Future.delayed(const Duration(milliseconds: 100), () => _navigateToMetadataScreen(item)); },
|
||||
);
|
||||
},
|
||||
)),
|
||||
]),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
late PageController _pageController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -27,9 +35,42 @@ 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
|
||||
_shareSubscription = ShareIntentService().sharedUrlStream.listen((url) {
|
||||
_log.d('Received shared URL from stream: $url');
|
||||
_handleSharedUrl(url);
|
||||
});
|
||||
}
|
||||
|
||||
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 +92,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shareSubscription?.cancel();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -72,50 +114,107 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _showExitDialog() async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Exit App'),
|
||||
content: const Text('Are you sure you want to exit?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('No'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final trackState = ref.watch(trackProvider);
|
||||
|
||||
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: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
|
||||
// If on Search tab and can go back in track history, go back
|
||||
if (_currentIndex == 0 && trackState.canGoBack) {
|
||||
ref.read(trackProvider.notifier).goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// If on Search tab and has text in search bar or has content (but not loading), clear it
|
||||
// Don't clear while loading - this prevents clearing during share intent processing
|
||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// If not on Search tab, go to Search tab first
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// If loading, ignore back press
|
||||
if (trackState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already at root, show exit dialog
|
||||
final shouldPop = await _showExitDialog();
|
||||
if (shouldPop && context.mounted) {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
},
|
||||
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),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.search_outlined),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,84 @@
|
||||
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 = {};
|
||||
|
||||
bool _checkFileExists(String? filePath) {
|
||||
if (filePath == null) return false;
|
||||
if (_fileExistsCache.containsKey(filePath)) {
|
||||
return _fileExistsCache[filePath]!;
|
||||
}
|
||||
Future.microtask(() async {
|
||||
final exists = await File(filePath).exists();
|
||||
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||
setState(() => _fileExistsCache[filePath] = exists);
|
||||
}
|
||||
});
|
||||
_fileExistsCache[filePath] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final historyState = ref.watch(downloadHistoryProvider);
|
||||
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 +86,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 +99,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 ((queueState.isProcessing || queueState.queuedCount > 0) && (queueState.items.length > 1 || queueState.isPaused))
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
@@ -64,37 +126,21 @@ class QueueTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
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(
|
||||
queueState.isPaused
|
||||
? 'Paused'
|
||||
: '${queueState.completedCount}/${queueState.items.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(queueState.isPaused ? 'Resume' : 'Pause'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -103,170 +149,210 @@ class QueueTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Header with actions
|
||||
// Queue header
|
||||
if (queueState.items.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 (${queueState.items.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
|
||||
// Queue list
|
||||
if (queueState.items.isNotEmpty)
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
|
||||
(context, index) => _buildQueueItem(context, queueState.items[index], colorScheme),
|
||||
childCount: queueState.items.length,
|
||||
)),
|
||||
|
||||
// Empty state or fill remaining for scroll
|
||||
if (queueState.items.isEmpty)
|
||||
// History section header - show count only
|
||||
if (historyState.items.isNotEmpty && queueState.items.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('${historyState.items.length} ${historyState.items.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 (historyState.items.isNotEmpty && queueState.items.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
|
||||
if (historyState.items.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) => _buildHistoryGridItem(context, historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length,
|
||||
),
|
||||
),
|
||||
)
|
||||
: SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryItem(context, historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length,
|
||||
)),
|
||||
|
||||
// Empty state when both queue and history are empty
|
||||
if (queueState.items.isEmpty && historyState.items.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 +365,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),
|
||||
@@ -296,14 +381,32 @@ class QueueTab extends ConsumerWidget {
|
||||
);
|
||||
|
||||
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 +458,194 @@ 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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),
|
||||
Text(
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class AboutPage extends StatelessWidget {
|
||||
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)),
|
||||
errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,9 +142,16 @@ 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 to be darker than the card background
|
||||
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,
|
||||
@@ -177,9 +186,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 +210,70 @@ 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;
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
@@ -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,8 @@
|
||||
import 'package:flutter/material.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/widgets/settings_group.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
const OptionsSettingsPage({super.key});
|
||||
@@ -54,53 +56,96 @@ 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Lyrics section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Lyrics')),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate,
|
||||
title: 'Convert Japanese to Romaji',
|
||||
subtitle: 'Auto-convert Hiragana/Katakana lyrics',
|
||||
value: settings.convertLyricsToRomaji,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 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 +154,53 @@ 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 +232,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),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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() {
|
||||
@@ -113,6 +118,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 +192,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,
|
||||
@@ -623,6 +633,156 @@ 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,
|
||||
);
|
||||
|
||||
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 +855,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
title: const Text('Share'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: Implement share
|
||||
_shareFile(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -767,6 +927,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,72 @@
|
||||
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 {
|
||||
try {
|
||||
final client = http.Client();
|
||||
final request = http.Request('GET', Uri.parse(url));
|
||||
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();
|
||||
}
|
||||
|
||||
final 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.close();
|
||||
client.close();
|
||||
|
||||
_log.i('Downloaded to: $filePath');
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
_log.e('Error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -182,4 +183,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ class PlatformBridge {
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
bool convertLyricsToRomaji = false,
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
@@ -70,6 +71,7 @@ class PlatformBridge {
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
@@ -95,6 +97,7 @@ class PlatformBridge {
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
bool convertLyricsToRomaji = false,
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
@@ -116,6 +119,7 @@ class PlatformBridge {
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
@@ -230,4 +234,45 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,68 @@ 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?;
|
||||
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 +145,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,226 @@
|
||||
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
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest;
|
||||
|
||||
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,6 +17,84 @@ 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) {
|
||||
await notificationService.showUpdateDownloadComplete(
|
||||
version: widget.updateInfo.version,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// Open APK for installation
|
||||
await ApkDownloader.installApk(filePath);
|
||||
} else {
|
||||
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;
|
||||
@@ -50,7 +130,7 @@ class UpdateDialog extends StatelessWidget {
|
||||
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'v${updateInfo.version}',
|
||||
'v${widget.updateInfo.version}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -70,60 +150,71 @@ class UpdateDialog extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Changelog content (scrollable)
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
_formatChangelog(updateInfo.changelog),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
// Changelog content (scrollable) - hide when downloading
|
||||
if (!_isDownloading)
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
_formatChangelog(widget.updateInfo.changelog),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Download progress
|
||||
if (_isDownloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: _progress),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusText,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
actions: _isDownloading
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
]
|
||||
: [
|
||||
// Don't remind again button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.onDisableUpdates();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
'Don\'t remind',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
// Later button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Later'),
|
||||
),
|
||||
// Download button
|
||||
FilledButton(
|
||||
onPressed: _downloadAndInstall,
|
||||
child: const Text('Install'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 1.6.1+26
|
||||
|
||||
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
|
||||
|
||||