Compare commits

..

32 Commits

Author SHA1 Message Date
zarzet 0d8234ccd2 v1.5.5: History tab, share intent, artist support, lyrics viewer, folder organization 2026-01-02 06:47:49 +07:00
zarzet 0edd616c3d Remove redundant workflows, keep only release.yml 2026-01-02 04:44:26 +07:00
zarzet 9ca0e8cf5c v1.5.0-hotfix6: Use sign-android-release action 2026-01-02 04:29:55 +07:00
zarzet 37b8682faa v1.5.0-hotfix5: Use key.properties per Flutter docs 2026-01-02 04:27:02 +07:00
zarzet 6563f0f2b3 Remove APK from tracking 2026-01-02 04:17:44 +07:00
zarzet 562fd4d7bb v1.5.0-hotfix4: Create keystore.properties in workflow 2026-01-02 04:17:24 +07:00
zarzet 7aa3e77df1 Remove APK 2026-01-02 04:10:07 +07:00
zarzet 4caa803eb2 v1.5.0-hotfix3: Decode keystore in workflow 2026-01-02 04:09:49 +07:00
zarzet 6d5c9d0f91 Remove accidentally committed APK 2026-01-02 03:52:00 +07:00
zarzet 1b2ad4cdd5 v1.5.0-hotfix2: Fix CI signing config 2026-01-02 03:51:41 +07:00
zarzet 33e8ddd758 v1.5.0-hotfix: Fix app signing, add in-app update 2026-01-02 03:36:11 +07:00
zarzet d227d57545 v1.5.0: UI rework, multi-progress tracking, performance optimizations 2026-01-02 02:54:50 +07:00
zarzet db1439e08f fix: improve changelog extraction in release workflow 2026-01-02 00:15:14 +07:00
zarzet 47e7850ee0 chore: remove screenshot 2026-01-02 00:14:02 +07:00
zarzet 3ea665dab4 docs: add v1.2.0 changelog, update gitignore 2026-01-02 00:12:36 +07:00
zarzet bd4acdf222 v1.2.0: Track Metadata Screen, Hi-Res fix, Settings navigation fix 2026-01-02 00:10:30 +07:00
zarzet 8ac679003e v1.1.1: UI fixes, MIT license, history persistence improvements 2026-01-01 22:29:40 +07:00
zarzet 6a1265eac3 v1.1.0: Parallel downloads, bug fixes, history persistence 2026-01-01 22:09:39 +07:00
zarzet 9570547ff9 fix: trim whitespace from metadata fields to prevent filename issues 2026-01-01 21:45:33 +07:00
zarzet ef62fb218a fix: add connection pooling and periodic cleanup to prevent TCP exhaustion 2026-01-01 21:42:25 +07:00
zarzet ba5c91090c fix: add progress tracking for BTS downloads and persist history 2026-01-01 21:28:00 +07:00
zarzet c454bcd5ee docs: center logo in README 2026-01-01 21:14:17 +07:00
zarzet 4d2ee6fca6 docs: resize logo to 128px 2026-01-01 21:13:12 +07:00
zarzet 89851bbd62 docs: add screenshots to README 2026-01-01 21:11:22 +07:00
zarzet 2c614f9e2f chore: bump version to 1.0.5 2026-01-01 20:54:36 +07:00
zarzet f36bee1095 feat: add GitHub links and credits to Settings 2026-01-01 20:49:12 +07:00
zarzet e4218a1894 fix: correct GobackendSetDownloadDirectory call signature for iOS 2026-01-01 20:46:08 +07:00
zarzet db335f5ba6 chore: bump version to 1.0.3 2026-01-01 20:36:02 +07:00
zarzet ab9869a849 fix: add XCFramework to Xcode project dynamically for iOS build 2026-01-01 20:35:10 +07:00
zarzet 34791310b7 fix: remove empty assets/icons folder reference 2026-01-01 20:25:53 +07:00
zarzet 97e366b5ef chore: bump version to 1.0.2 2026-01-01 20:13:47 +07:00
zarzet 3a4019a55e fix: trigger release workflow directly from auto-release 2026-01-01 20:12:53 +07:00
62 changed files with 7049 additions and 2197 deletions
-75
View File
@@ -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
-62
View File
@@ -1,62 +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-release:
needs: check-version
if: needs.check-version.outputs.version_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: 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 }}
-82
View File
@@ -1,82 +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)
echo "=== Debug.xcconfig ==="
cat ios/Flutter/Debug.xcconfig
echo "=== Release.xcconfig ==="
cat ios/Flutter/Release.xcconfig
- 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
+154 -50
View File
@@ -12,17 +12,14 @@ on:
default: 'v1.0.0'
jobs:
build-android:
# Get version first (quick job)
get-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get version
id: get_version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
@@ -30,6 +27,15 @@ jobs:
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
# Android and iOS build in PARALLEL
build-android:
runs-on: ubuntu-latest
needs: get-version
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
@@ -42,6 +48,16 @@ jobs:
go-version: '1.21'
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK
uses: android-actions/setup-android@v3
@@ -70,18 +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=${{ steps.get_version.outputs.version }}
VERSION=${{ needs.get-version.outputs.version }}
cd build/app/outputs/flutter-apk
# Rename split APKs
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
# Also rename universal if exists
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
@@ -92,7 +119,7 @@ jobs:
build-ios:
runs-on: macos-latest
needs: build-android
needs: get-version # Only depends on version, NOT android build!
steps:
- name: Checkout repository
@@ -104,6 +131,14 @@ jobs:
go-version: '1.21'
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: pods-${{ runner.os }}-
- name: Install gomobile
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
@@ -119,13 +154,48 @@ jobs:
- 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)
echo "=== Debug.xcconfig ==="
cat ios/Flutter/Debug.xcconfig
echo "=== Release.xcconfig ==="
cat ios/Flutter/Release.xcconfig
- 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
@@ -144,7 +214,7 @@ jobs:
- name: Create IPA
run: |
VERSION=${{ needs.build-android.outputs.version }}
VERSION=${{ needs.get-version.outputs.version }}
mkdir -p build/ios/ipa
cd build/ios/iphoneos
mkdir Payload
@@ -160,7 +230,7 @@ jobs:
create-release:
runs-on: ubuntu-latest
needs: [build-android, build-ios]
needs: [get-version, build-android, build-ios]
permissions:
contents: write
@@ -168,6 +238,31 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract changelog for version
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
echo "Looking for version: $VERSION_NUM"
# Extract changelog section for this version using sed
# Find the line with version, then print until next version header or end
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
echo "No changelog found for version $VERSION_NUM"
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
uses: actions/download-artifact@v4
with:
@@ -180,37 +275,46 @@ jobs:
name: ios-ipa
path: ./release
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
## SpotiFLAC $VERSION
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
### What's New
HEADER
# Replace $VERSION in header
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
cat /tmp/changelog.txt >> /tmp/release_body.txt
cat >> /tmp/release_body.txt << FOOTER
---
### Downloads
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
FOOTER
echo "Release body:"
cat /tmp/release_body.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.build-android.outputs.version }}
name: SpotiFLAC ${{ needs.build-android.outputs.version }}
body: |
## SpotiFLAC ${{ needs.build-android.outputs.version }}
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
### Downloads
- **Android (arm64)**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-arm64.apk` (recommended for most devices)
- **Android (arm32)**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-arm32.apk` (for older devices)
- **iOS**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-ios-unsigned.ipa` (requires sideloading)
### Features
- Search Spotify tracks, albums, and playlists
- Download in FLAC quality from multiple sources
- Automatic fallback to available services
- Embedded metadata and cover art
- Lyrics support (synced and plain)
- Material 3 Expressive UI with dynamic colors
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
---
*Note: iOS IPA is unsigned and requires sideloading*
files: |
./release/*
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
prerelease: false
env:
+44 -4
View File
@@ -7,8 +7,48 @@ Thumbs.db
.vscode/
*.iml
# Kiro specs (optional - remove if you want to track specs)
# .kiro/
# Kiro specs (development only)
.kiro/
# Reference folder (if you don't want to include it)
# referensi/
# Reference folder (development only)
referensi/
# Development notes
COMPARISON_PC_vs_ANDROID.md
# Old spotiflac_android folder (moved to root)
spotiflac_android/
# Flutter/Dart
.dart_tool/
.packages
build/
*.lock
!pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies
.metadata
*.apk
# Go backend build artifacts
go_backend/*.aar
go_backend/*.jar
go_backend/*.exe
go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/
android/local.properties
android/*.iml
android/key.properties
android/*.jks
android/*.keystore
android/app/*.jks
# iOS
ios/Frameworks/
ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
+231
View File
@@ -0,0 +1,231 @@
# Changelog
## [1.5.5] - 2026-01-02
### Added
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
- Supports track, album, playlist, and artist URLs
- Auto-fetches metadata when link is shared
- Works with both `open.spotify.com` URLs and `spotify:` URIs
- **Lyrics Viewer**: View lyrics for downloaded tracks in Track Metadata screen
- Fetches lyrics from LRCLIB on-demand
- Clean display without timestamps
- Copy lyrics to clipboard
- **Artist URL Support**: Paste artist URL to browse their discography
- Shows all albums, singles, and compilations
- Horizontal scrollable album cards grouped by type
- Tap any album to view and download its tracks
- **Folder Organization**: Organize downloads into folders by artist or album
- Options: None, By Artist, By Album, By Artist & Album
- Configurable in Settings > Download
- **Japanese Lyrics to Romaji**: Auto-convert Hiragana/Katakana lyrics to romaji
- Useful for non-Japanese speakers who want to sing along
- Toggle in Settings > Options > Lyrics
- Kanji characters are preserved (requires dictionary lookup)
- **History View Mode**: Choose between grid or list view for download history
- Grid view shows album art in a 3-column layout (default)
- List view shows detailed track info with date
- Configurable in Settings > Appearance > Layout
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
### Changed
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
- Shows download queue at top when active
- Completed downloads auto-move to history section
- Cleaner separation between active downloads and history
- **Smarter Back Navigation**: Back button now navigates properly
- Goes back through search history (album → artist → empty)
- Returns to Search tab from other tabs
- Only shows exit dialog when truly at root
### Fixed
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
### Improved
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
- **Back Navigation**: Android back button now works as expected - returns to previous view (album → artist → empty search)
## [1.5.0-hotfix6] - 2026-01-02
### Fixed
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
## [1.5.0-hotfix5] - 2026-01-02
### Fixed
- **App Signing**: Use key.properties as per Flutter official documentation
## [1.5.0-hotfix4] - 2026-01-02
### Fixed
- **App Signing**: Create keystore.properties in workflow for Gradle
## [1.5.0-hotfix] - 2026-01-02
### Important Notice
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
### Added
- **In-App Update**: Download and install updates directly from the app
- Progress bar shows download status
- Automatic device architecture detection (arm64/arm32)
- Downloads correct APK for your device
- **Consistent App Signing**: All future releases will use the same signing key
### Fixed
- **Update Checker**: Now downloads APK directly instead of opening browser
## [1.5.0] - 2026-01-02
### Added
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
- Progress bar in notification during download
- Completion notification when track finishes
- Summary notification when all downloads complete
- **Notification Permission in Setup**: Android 13+ users will be prompted for notification permission during initial setup
- New step in setup wizard for notification permission
- Option to skip if user doesn't want notifications
- **Per-Item Queue Controls**: Each track in download queue now has individual controls
- Cancel button for queued items
- Stop button for currently downloading items
- Retry and Remove buttons for failed/skipped items
- Visual progress bar with percentage for each downloading track
- **Pull-to-Refresh on Home**: Swipe down to clear URL input and fetched tracks
- No need to exit app to clear current search/fetch
- **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage
- Previously concurrent downloads jumped from 0% to 100%
- Now each track shows real-time progress when downloading in parallel
- **In-App Update**: Download and install updates directly from the app
- Progress bar shows download status
- Automatic device architecture detection (arm64/arm32)
- Downloads correct APK for your device
### Changed
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
- **Queue UI Redesign**: Card-based layout with clearer status indicators
- Removed global pause/resume in favor of per-item controls
- Better visual hierarchy with cover art, track info, and action buttons
- **Settings UI**: Redesigned with category-based navigation (One UI style)
- Main settings tab with 4 categories: Appearance, Download, Options, About
- Each category opens a detail page
- Large title at top with menu items below
- One-handed friendly layout
- **Collapsing Toolbar**: Implemented One UI style collapsing header for all tabs
- Title animates from 28px (expanded) to 20px (collapsed)
- Back button only on settings detail pages
- Consistent across Home, Downloads, and Settings tabs
- **Home Search Bar Redesign**: More prominent and user-friendly input
- Larger card-style search bar with border outline
- Tap to open bottom sheet with full input experience
- Paste and Search buttons clearly visible
- Helper text showing supported URL types
- **Empty State Improved**: Better onboarding for new users
- "Ready to Download" title with icon
- Clear instructions on how to use the app
- "Add Music" button for quick access
### Technical
- Added `flutter_local_notifications` package for notifications
- Added notification permission request in setup screen for Android 13+
- Enabled core library desugaring for all Android subprojects
- Added multi-progress tracking in Go backend (`ItemProgress`, `ItemProgressWriter`)
- Added `GetAllDownloadProgress`, `InitItemProgress`, `FinishItemProgress`, `ClearItemProgress` exports
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
### Performance
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
- Optimized state management: Use `select()` to only rebuild when specific state changes
- Smoother animations: Changed to `BouncingScrollPhysics` and `Curves.easeOutCubic`
## [1.2.0] - 2026-01-02
### Added
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
- Material Expressive 3 design with cover art header and gradient
- Hero animation from list to detail view
- Displays: track name, artist, album artist, album, track number, disc number, duration, release date, ISRC, Spotify ID, quality, service, download date
- File info: format (FLAC/M4A), file size, quality badge, service badge with colors
- Tap to copy ISRC and Spotify ID
- "Open in Spotify" button to open track in Spotify app/browser
- File path display with copy functionality
- Play and Delete action buttons
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
### Fixed
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
### Changed
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
- Play button still available for quick playback
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
- Removed unused `history_screen.dart` and `history_tab.dart` files
## [1.1.2] - 2026-01-01
### Added
- **Update Checker**: Automatic check for new versions from GitHub releases
- Shows changelog in update dialog
- Option to disable update notifications
- **Release Changelog**: GitHub releases now include full changelog
### Changed
- Updated version to 1.1.2
## [1.1.1] - 2026-01-01
### Fixed
- **About Dialog**: Custom About dialog with cleaner layout
- **Setup Screen**: Fixed step indicator line alignment
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
- **Copyright Year**: Updated to 2026
### Changed
- Removed Theme Preview from Settings
- Added MIT License
## [1.1.0] - 2026-01-01
### Added
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
- Default: Sequential (1 at a time) for stability
- Options: 1, 2, or 3 concurrent downloads
- Warning about potential rate limiting from streaming services
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
### Fixed
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
### Changed
- Updated version to 1.1.0
### Technical Details
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
- Added shared `http.Transport` with connection pooling in `httputil.go`
- Added `CleanupConnections()` export for Flutter to call via method channel
## [1.0.5] - Previous Release
- Material Expressive 3 UI
- Dynamic color support
- Swipe navigation with PageView
- Settings as bottom navigation tab
- APK size optimization
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 zarzet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19 -4
View File
@@ -1,9 +1,9 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
![Image](icon.png)
<div align="center">
<img src="icon.png" width="128" />
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
@@ -13,9 +13,24 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Screenshot
## Features
<!-- ![Image](screenshot.png) -->
- Download tracks, albums, and playlists from Spotify links
- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
- Material Expressive 3 design with dynamic colors
- High performance rendering with Impeller (Vulkan)
- Concurrent downloads up to 3 simultaneous
- Real-time download progress tracking
- Download notifications
## Screenshots
<p align="center">
<img src="assets/images/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
+26 -5
View File
@@ -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(
@@ -72,7 +93,7 @@ repositories {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(files("libs/gobackend.aar"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+12
View File
@@ -11,6 +11,7 @@
<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" />
<application
android:label="SpotiFLAC"
@@ -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>
@@ -71,6 +71,33 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"getAllDownloadProgress" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
}
result.success(response)
}
"initItemProgress" -> {
val itemId = call.argument<String>("item_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.initItemProgress(itemId)
}
result.success(null)
}
"finishItemProgress" -> {
val itemId = call.argument<String>("item_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.finishItemProgress(itemId)
}
result.success(null)
}
"clearItemProgress" -> {
val itemId = call.argument<String>("item_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.clearItemProgress(itemId)
}
result.success(null)
}
"setDownloadDirectory" -> {
val path = call.argument<String>("path") ?: ""
withContext(Dispatchers.IO) {
@@ -127,6 +154,12 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"cleanupConnections" -> {
withContext(Dispatchers.IO) {
Gobackend.cleanupConnections()
}
result.success(null)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="external_files" path="." />
<cache-path name="cache" path="." />
<files-path name="files" path="." />
</paths>
+9
View File
@@ -10,10 +10,19 @@ subprojects {
if (project.hasProperty("android")) {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Enable multidex for all subprojects
defaultConfig {
multiDexEnabled = true
}
}
// Add desugaring dependency to all Android subprojects
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+34 -8
View File
@@ -202,12 +202,18 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set current file being downloaded
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
@@ -228,6 +234,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
}
out, err := os.Create(outputPath)
@@ -236,14 +245,20 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
}
defer out.Close()
// Track download progress
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
// Use appropriate progress writer
var bytesWritten int64
if itemID != "" {
pw := NewItemProgressWriter(out, itemID)
bytesWritten, err = io.Copy(pw, resp.Body)
} else {
pw := NewProgressWriter(out)
bytesWritten, err = io.Copy(pw, resp.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
return nil
}
@@ -298,8 +313,8 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
return "EXISTS:" + outputPath, nil
}
// Download file
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
// Download file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
@@ -349,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)
+51
View File
@@ -5,6 +5,7 @@ package gobackend
import (
"context"
"encoding/json"
"strings"
"time"
)
@@ -98,12 +99,15 @@ type DownloadRequest struct {
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
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"`
ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking
}
// DownloadResponse represents the result of a download
@@ -124,6 +128,13 @@ func DownloadTrack(requestJSON string) (string, error) {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
var filePath string
var err error
@@ -172,6 +183,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
@@ -239,6 +257,33 @@ func GetDownloadProgress() string {
return string(jsonBytes)
}
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
func GetAllDownloadProgress() string {
return GetMultiProgress()
}
// InitItemProgress initializes progress tracking for a download item
func InitItemProgress(itemID string) {
StartItemProgress(itemID)
}
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
// Don't remove immediately - let Flutter poll one more time to see 100%
}
// ClearItemProgress removes progress tracking for a specific item
func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID)
}
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() {
CloseIdleConnections()
}
// SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
@@ -329,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,
+2 -1
View File
@@ -63,7 +63,8 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s)
}
}
return ""
+48 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"math/rand"
"net"
"net/http"
"strconv"
"time"
@@ -41,13 +42,59 @@ const (
DefaultRetryDelay = 1 * time.Second // Initial retry delay
)
// Shared transport with connection pooling to prevent TCP exhaustion
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: true,
}
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{
Transport: sharedTransport,
Timeout: DefaultTimeout,
}
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{
Transport: sharedTransport,
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: sharedTransport,
Timeout: timeout,
}
}
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client {
return sharedClient
}
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
+141 -5
View File
@@ -1,10 +1,11 @@
package gobackend
import (
"encoding/json"
"sync"
)
// DownloadProgress represents current download progress
// DownloadProgress represents current download progress (legacy single download)
type DownloadProgress struct {
CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"`
@@ -14,20 +15,128 @@ type DownloadProgress struct {
IsDownloading bool `json:"is_downloading"`
}
// ItemProgress represents progress for a single download item
type ItemProgress struct {
ItemID string `json:"item_id"`
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
IsDownloading bool `json:"is_downloading"`
}
// MultiProgress holds progress for multiple concurrent downloads
type MultiProgress struct {
Items map[string]*ItemProgress `json:"items"`
}
var (
currentProgress DownloadProgress
progressMu sync.RWMutex
downloadDir string
downloadDirMu sync.RWMutex
// Multi-download progress tracking
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
)
// getProgress returns current download progress
// getProgress returns current download progress (legacy)
func getProgress() DownloadProgress {
progressMu.RLock()
defer progressMu.RUnlock()
return currentProgress
}
// GetMultiProgress returns progress for all active downloads as JSON
func GetMultiProgress() string {
multiMu.RLock()
defer multiMu.RUnlock()
jsonBytes, err := json.Marshal(multiProgress)
if err != nil {
return "{\"items\":{}}"
}
return string(jsonBytes)
}
// GetItemProgress returns progress for a specific item as JSON
func GetItemProgress(itemID string) string {
multiMu.RLock()
defer multiMu.RUnlock()
if item, ok := multiProgress.Items[itemID]; ok {
jsonBytes, _ := json.Marshal(item)
return string(jsonBytes)
}
return "{}"
}
// StartItemProgress initializes progress tracking for an item
func StartItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
multiProgress.Items[itemID] = &ItemProgress{
ItemID: itemID,
BytesTotal: 0,
BytesReceived: 0,
Progress: 0,
IsDownloading: true,
}
}
// SetItemBytesTotal sets total bytes for an item
func SetItemBytesTotal(itemID string, total int64) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.BytesTotal = total
}
}
// SetItemBytesReceived sets bytes received for an item
func SetItemBytesReceived(itemID string, received int64) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.BytesReceived = received
if item.BytesTotal > 0 {
item.Progress = float64(received) / float64(item.BytesTotal)
}
}
}
// CompleteItemProgress marks an item as complete
func CompleteItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = 1.0
item.IsDownloading = false
}
}
// RemoveItemProgress removes progress tracking for an item
func RemoveItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
delete(multiProgress.Items, itemID)
}
// ClearAllItemProgress clears all item progress
func ClearAllItemProgress() {
multiMu.Lock()
defer multiMu.Unlock()
multiProgress.Items = make(map[string]*ItemProgress)
}
// Legacy functions for backward compatibility
// SetDownloadProgress sets the current download progress (MB downloaded)
func SetDownloadProgress(mbDownloaded float64) {
progressMu.Lock()
@@ -47,7 +156,6 @@ func SetDownloadSpeed(speedMBps float64) {
func SetCurrentFile(filename string) {
progressMu.Lock()
defer progressMu.Unlock()
// Reset progress for new file
currentProgress.BytesReceived = 0
currentProgress.BytesTotal = 0
currentProgress.Progress = 0
@@ -101,7 +209,7 @@ func SetBytesReceived(received int64) {
}
}
// ProgressWriter wraps io.Writer to track download progress
// ProgressWriter wraps io.Writer to track download progress (legacy single)
type ProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
total int64
@@ -110,7 +218,6 @@ type ProgressWriter struct {
// NewProgressWriter creates a new progress writer wrapping an io.Writer
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
// Reset bytes received when starting new download
SetBytesReceived(0)
return &ProgressWriter{
writer: w,
@@ -135,3 +242,32 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
}
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
return &ItemProgressWriter{
writer: w,
itemID: itemID,
current: 0,
}
}
// Write implements io.Writer
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
if err != nil {
return n, err
}
pw.current += int64(n)
SetItemBytesReceived(pw.itemID, pw.current)
// Also update legacy progress for backward compatibility
SetBytesReceived(pw.current)
return n, nil
}
+47 -8
View File
@@ -261,12 +261,18 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
}
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set current file being downloaded
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
@@ -285,6 +291,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
}
out, err := os.Create(outputPath)
@@ -293,9 +302,14 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
}
defer out.Close()
// Use ProgressWriter for tracking
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
// Use appropriate progress writer
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
}
return err
}
@@ -346,14 +360,28 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
return "EXISTS:" + outputPath, nil
}
// Map quality from Tidal format to Qobuz format
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
qobuzQuality := "27" // Default to highest quality
switch req.Quality {
case "LOSSLESS":
qobuzQuality = "6" // 16-bit FLAC
case "HI_RES":
qobuzQuality = "7" // 24-bit 96kHz
case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz
}
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
// Download file
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
// Download file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
@@ -398,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)
+112
View File
@@ -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"`
+76 -13
View File
@@ -640,17 +640,23 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
// DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Handle manifest-based download
if strings.HasPrefix(downloadURL, "MANIFEST:") {
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath)
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
}
// Set current file being downloaded
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
@@ -669,6 +675,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
}
out, err := os.Create(outputPath)
@@ -677,13 +686,18 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
}
defer out.Close()
// Use ProgressWriter for tracking
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
// Use appropriate progress writer
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
}
return err
}
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) error {
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
@@ -693,9 +707,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
Timeout: 120 * time.Second,
}
// If we have a direct URL (BTS format), download directly
// If we have a direct URL (BTS format), download directly with progress tracking
if directURL != "" {
resp, err := client.Get(directURL)
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
}
req, err := http.NewRequest("GET", directURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -705,13 +735,28 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// Set total bytes for progress tracking
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
}
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
// Use appropriate progress writer
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
}
return err
}
@@ -842,14 +887,21 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
return "EXISTS:" + outputPath, nil
}
// Determine quality to use (default to LOSSLESS if not specified)
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
fmt.Printf("[Tidal] Using quality: %s\n", quality)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS")
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
// Download file
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
// Download file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
@@ -909,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)
+28 -1
View File
@@ -90,10 +90,33 @@ import Gobackend // Import Go framework
let response = GobackendGetDownloadProgress()
return response
case "getAllDownloadProgress":
let response = GobackendGetAllDownloadProgress()
return response
case "initItemProgress":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendInitItemProgress(itemId)
return nil
case "finishItemProgress":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendFinishItemProgress(itemId)
return nil
case "clearItemProgress":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendClearItemProgress(itemId)
return nil
case "setDownloadDirectory":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
try GobackendSetDownloadDirectory(path)
GobackendSetDownloadDirectory(path, &error)
if let error = error { throw error }
return nil
case "checkDuplicate":
@@ -144,6 +167,10 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "cleanupConnections":
GobackendCleanupConnections()
return nil
default:
throw NSError(
domain: "SpotiFLAC",
+3 -2
View File
@@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
final _routerProvider = Provider<GoRouter>((ref) {
final settings = ref.watch(settingsProvider);
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
return GoRouter(
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
initialLocation: isFirstLaunch ? '/setup' : '/',
routes: [
GoRoute(
path: '/',
+17
View File
@@ -0,0 +1,17 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '1.5.5';
static const String buildNumber = '22';
static const String fullVersion = '$version+$buildNumber';
static const String appName = 'SpotiFLAC';
static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet';
static const String originalAuthor = 'afkarxyz';
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
}
+28 -3
View File
@@ -1,12 +1,37 @@
import 'package:flutter/material.dart';
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() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize notification service
await NotificationService().initialize();
// Initialize share intent service
await ShareIntentService().initialize();
runApp(
const ProviderScope(
child: SpotiFLACApp(),
ProviderScope(
child: const _EagerInitialization(
child: SpotiFLACApp(),
),
),
);
}
/// Widget to eagerly initialize providers that need to load data on startup
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Eagerly initialize download history provider to load from storage
ref.watch(downloadHistoryProvider);
return child;
}
}
+12 -30
View File
@@ -7,21 +7,22 @@ part of 'download_item.dart';
// **************************************************************************
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
id: json['id'] as String,
track: Track.fromJson(json['track'] as Map<String, dynamic>),
service: json['service'] as String,
status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
id: json['id'] as String,
track: Track.fromJson(json['track'] as Map<String, dynamic>),
service: json['service'] as String,
status:
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
<String, dynamic>{
'id': instance.id,
'track': instance.track.toJson(),
'track': instance.track,
'service': instance.service,
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
@@ -37,22 +38,3 @@ const _$DownloadStatusEnumMap = {
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
};
K? $enumDecodeNullable<K, V>(
Map<K, V> enumValues,
Object? source, {
K? unknownValue,
}) {
if (source == null) {
return null;
}
return enumValues.entries
.singleWhere(
(e) => e.value == source,
orElse: () => throw ArgumentError(
'`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}',
),
)
.key;
}
+24
View File
@@ -12,6 +12,12 @@ class AppSettings {
final bool embedLyrics;
final bool maxQualityCover;
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
const AppSettings({
this.defaultService = 'tidal',
@@ -22,6 +28,12 @@ class AppSettings {
this.embedLyrics = true,
this.maxQualityCover = true,
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
});
AppSettings copyWith({
@@ -33,6 +45,12 @@ class AppSettings {
bool? embedLyrics,
bool? maxQualityCover,
bool? isFirstLaunch,
int? concurrentDownloads,
bool? checkForUpdates,
bool? hasSearchedBefore,
String? folderOrganization,
bool? convertLyricsToRomaji,
String? historyViewMode,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -43,6 +61,12 @@ class AppSettings {
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
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,
);
}
+21 -9
View File
@@ -7,15 +7,21 @@ part of 'settings.dart';
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
defaultService: json['defaultService'] as String? ?? 'tidal',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
);
defaultService: json['defaultService'] as String? ?? 'tidal',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
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? ?? 'list',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
<String, dynamic>{
@@ -27,4 +33,10 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
'historyViewMode': instance.historyViewMode,
};
+39 -38
View File
@@ -7,37 +7,38 @@ part of 'track.dart';
// **************************************************************************
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
id: json['id'] as String,
name: json['name'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>),
);
id: json['id'] as String,
name: json['name'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'availability': instance.availability?.toJson(),
};
'id': instance.id,
'name': instance.name,
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'availability': instance.availability,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
ServiceAvailability(
@@ -50,12 +51,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
);
Map<String, dynamic> _$ServiceAvailabilityToJson(
ServiceAvailability instance) =>
<String, dynamic>{
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
};
ServiceAvailability instance,
) => <String, dynamic>{
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
};
+567 -117
View File
@@ -1,14 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
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';
// Download History Item model
class DownloadHistoryItem {
@@ -16,21 +20,76 @@ class DownloadHistoryItem {
final String trackName;
final String artistName;
final String albumName;
final String? albumArtist;
final String? coverUrl;
final String filePath;
final String service;
final DateTime downloadedAt;
// Additional metadata
final String? isrc;
final String? spotifyId;
final int? trackNumber;
final int? discNumber;
final int? duration;
final String? releaseDate;
final String? quality;
const DownloadHistoryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist,
this.coverUrl,
required this.filePath,
required this.service,
required this.downloadedAt,
this.isrc,
this.spotifyId,
this.trackNumber,
this.discNumber,
this.duration,
this.releaseDate,
this.quality,
});
Map<String, dynamic> toJson() => {
'id': id,
'trackName': trackName,
'artistName': artistName,
'albumName': albumName,
'albumArtist': albumArtist,
'coverUrl': coverUrl,
'filePath': filePath,
'service': service,
'downloadedAt': downloadedAt.toIso8601String(),
'isrc': isrc,
'spotifyId': spotifyId,
'trackNumber': trackNumber,
'discNumber': discNumber,
'duration': duration,
'releaseDate': releaseDate,
'quality': quality,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
id: json['id'] as String,
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
filePath: json['filePath'] as String,
service: json['service'] as String,
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
isrc: json['isrc'] as String?,
spotifyId: json['spotifyId'] as String?,
trackNumber: json['trackNumber'] as int?,
discNumber: json['discNumber'] as int?,
duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?,
quality: json['quality'] as String?,
);
}
// Download History State
@@ -46,23 +105,73 @@ class DownloadHistoryState {
// Download History Notifier (Riverpod 3.x)
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const _storageKey = 'download_history';
bool _isLoaded = false;
@override
DownloadHistoryState build() {
// Load history from storage on init
_loadFromStorageSync();
return const DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromStorageSync() {
if (_isLoaded) return;
Future.microtask(() async {
await _loadFromStorage();
_isLoaded = true;
});
}
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_storageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
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');
} else {
print('[DownloadHistory] No history found in storage');
}
} catch (e) {
print('[DownloadHistory] Failed to load history: $e');
}
}
Future<void> _saveToStorage() async {
try {
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');
} catch (e) {
print('[DownloadHistory] Failed to save history: $e');
}
}
/// Force reload from storage (useful after app restart)
Future<void> reloadFromStorage() async {
await _loadFromStorage();
}
void addToHistory(DownloadHistoryItem item) {
state = state.copyWith(items: [item, ...state.items]);
_saveToStorage();
}
void removeFromHistory(String id) {
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
);
_saveToStorage();
}
void clearHistory() {
state = const DownloadHistoryState();
_saveToStorage();
}
}
@@ -75,45 +184,62 @@ class DownloadQueueState {
final List<DownloadItem> items;
final DownloadItem? currentDownload;
final bool isProcessing;
final bool isPaused; // NEW: pause state
final String outputDir;
final String filenameFormat;
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
final bool autoFallback;
final int concurrentDownloads; // 1 = sequential, max 3
const DownloadQueueState({
this.items = const [],
this.currentDownload,
this.isProcessing = false,
this.isPaused = false,
this.outputDir = '',
this.filenameFormat = '{artist} - {title}',
this.audioQuality = 'LOSSLESS',
this.autoFallback = true,
this.concurrentDownloads = 1,
});
DownloadQueueState copyWith({
List<DownloadItem>? items,
DownloadItem? currentDownload,
bool? isProcessing,
bool? isPaused,
String? outputDir,
String? filenameFormat,
String? audioQuality,
bool? autoFallback,
int? concurrentDownloads,
}) {
return DownloadQueueState(
items: items ?? this.items,
currentDownload: currentDownload ?? this.currentDownload,
isProcessing: isProcessing ?? this.isProcessing,
isPaused: isPaused ?? this.isPaused,
outputDir: outputDir ?? this.outputDir,
filenameFormat: filenameFormat ?? this.filenameFormat,
audioQuality: audioQuality ?? this.audioQuality,
autoFallback: autoFallback ?? this.autoFallback,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
);
}
int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length;
int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length;
int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length;
int get activeDownloadsCount => items.where((i) => i.status == DownloadStatus.downloading).length;
}
// Download Queue Notifier (Riverpod 3.x)
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup
static const _cleanupInterval = 50; // Cleanup every 50 downloads
final NotificationService _notificationService = NotificationService();
int _totalQueuedAtStart = 0; // Track total items when queue started
@override
DownloadQueueState build() {
@@ -137,6 +263,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final percentage = bytesReceived / bytesTotal;
updateProgress(itemId, percentage);
// Update notification with progress
final currentItem = state.currentDownload;
if (currentItem != null) {
_notificationService.showDownloadProgress(
trackName: currentItem.track.name,
artistName: currentItem.track.artistName,
progress: bytesReceived,
total: bytesTotal,
);
}
// Log progress
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
@@ -148,6 +285,56 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
}
/// Start multi-progress polling for concurrent downloads
void _startMultiProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
try {
final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
for (final entry in items.entries) {
final itemId = entry.key;
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
if (isDownloading && bytesTotal > 0) {
final percentage = bytesReceived / bytesTotal;
updateProgress(itemId, percentage);
// 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)');
}
}
// Update notification with first active download
if (items.isNotEmpty) {
final firstEntry = items.entries.first;
final firstProgress = firstEntry.value as Map<String, dynamic>;
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
// Find the item to get track info
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
if (downloadingItems.isNotEmpty) {
_notificationService.showDownloadProgress(
trackName: '${downloadingItems.length} downloads',
artistName: 'Downloading...',
progress: bytesReceived,
total: bytesTotal > 0 ? bytesTotal : 1,
);
}
}
} catch (e) {
// Ignore polling errors
}
});
}
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressTimer = null;
@@ -199,15 +386,67 @@ 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);
print('[DownloadQueue] Created folder: $fullPath');
}
return fullPath;
}
return baseDir;
}
void updateSettings(AppSettings settings) {
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
filenameFormat: settings.filenameFormat,
audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback,
concurrentDownloads: settings.concurrentDownloads,
);
}
String addToQueue(Track track, String service) {
// Sync settings before adding to queue
final settings = ref.read(settingsProvider);
updateSettings(settings);
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
final item = DownloadItem(
id: id,
@@ -227,6 +466,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void addMultipleToQueue(List<Track> tracks, String service) {
// Sync settings before adding to queue
final settings = ref.read(settingsProvider);
updateSettings(settings);
final newItems = tracks.map((track) {
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
return DownloadItem(
@@ -280,7 +523,59 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void clearAll() {
state = const DownloadQueueState();
state = state.copyWith(items: [], isPaused: false);
}
/// Pause the download queue
void pauseQueue() {
if (state.isProcessing && !state.isPaused) {
state = state.copyWith(isPaused: true);
_notificationService.cancelDownloadNotification();
print('[DownloadQueue] Queue paused');
}
}
/// Resume the download queue
void resumeQueue() {
if (state.isPaused) {
state = state.copyWith(isPaused: false);
print('[DownloadQueue] Queue resumed');
// If there are still queued items, continue processing
if (state.queuedCount > 0 && !state.isProcessing) {
Future.microtask(() => _processQueue());
}
}
}
/// Toggle pause/resume
void togglePause() {
if (state.isPaused) {
resumeQueue();
} else {
pauseQueue();
}
}
/// Retry a failed download
void retryItem(String id) {
final items = state.items.map((item) {
if (item.id == id && item.status == DownloadStatus.failed) {
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
}
return item;
}).toList();
state = state.copyWith(items: items);
// Start processing if not already
if (!state.isProcessing) {
Future.microtask(() => _processQueue());
}
}
/// Remove a specific item from queue
void removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items);
}
/// Embed metadata and cover to a FLAC file after M4A conversion
@@ -353,6 +648,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(isProcessing: true);
print('[DownloadQueue] Starting queue processing...');
// Track total items at start for notification
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
// Ensure output directory is initialized before processing
if (state.outputDir.isEmpty) {
print('[DownloadQueue] Output dir empty, initializing...');
@@ -371,8 +669,52 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
print('[DownloadQueue] Output directory: ${state.outputDir}');
print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}');
// Use parallel processing if concurrentDownloads > 1
if (state.concurrentDownloads > 1) {
await _processQueueParallel();
} else {
await _processQueueSequential();
}
_stopProgressPolling();
// Final cleanup after queue finishes
if (_downloadCount > 0) {
print('[DownloadQueue] Final connection cleanup...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Final cleanup failed: $e');
}
_downloadCount = 0;
}
// Show queue completion notification
final completedCount = state.completedCount;
final failedCount = state.failedCount;
if (_totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete(
completedCount: completedCount,
failedCount: failedCount,
);
}
print('[DownloadQueue] Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
}
/// Sequential download processing (original behavior)
Future<void> _processQueueSequential() async {
while (true) {
// Check if paused
if (state.isPaused) {
print('[DownloadQueue] Queue is paused, waiting...');
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
final nextItem = state.items.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
@@ -388,130 +730,238 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
break;
}
print('[DownloadQueue] Processing: ${nextItem.track.name} by ${nextItem.track.artistName}');
print('[DownloadQueue] Cover URL: ${nextItem.track.coverUrl}');
state = state.copyWith(currentDownload: nextItem);
updateItemStatus(nextItem.id, DownloadStatus.downloading);
// Start progress polling
_startProgressPolling(nextItem.id);
await _downloadSingleItem(nextItem);
}
}
try {
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
result = await PlatformBridge.downloadWithFallback(
isrc: nextItem.track.isrc ?? '',
spotifyId: nextItem.track.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
albumArtist: nextItem.track.albumArtist,
coverUrl: nextItem.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: nextItem.track.trackNumber ?? 1,
discNumber: nextItem.track.discNumber ?? 1,
releaseDate: nextItem.track.releaseDate,
preferredService: nextItem.service,
);
/// Parallel download processing with worker pool
Future<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
// Start multi-progress polling for concurrent downloads
_startMultiProgressPolling();
while (true) {
// Check if paused - don't start new downloads but let active ones finish
if (state.isPaused) {
print('[DownloadQueue] Queue is paused, waiting for active downloads...');
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
} else {
result = await PlatformBridge.downloadTrack(
isrc: nextItem.track.isrc ?? '',
service: nextItem.service,
spotifyId: nextItem.track.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
albumArtist: nextItem.track.albumArtist,
coverUrl: nextItem.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: nextItem.track.trackNumber ?? 1,
discNumber: nextItem.track.discNumber ?? 1,
releaseDate: nextItem.track.releaseDate,
);
await Future.delayed(const Duration(milliseconds: 500));
}
// Stop progress polling for this item
_stopProgressPolling();
continue;
}
// Get queued items
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
print('[DownloadQueue] No more items to process');
break;
}
// Start new downloads up to max concurrent limit
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) {
final item = queuedItems.removeAt(0);
print('[DownloadQueue] Result: $result');
// Mark as downloading immediately to prevent double-processing
updateItemStatus(item.id, DownloadStatus.downloading);
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
print('[DownloadQueue] 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...');
updateItemStatus(nextItem.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
print('[DownloadQueue] Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
nextItem.track,
);
print('[DownloadQueue] Metadata and cover embedded successfully');
} catch (e) {
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
}
}
}
updateItemStatus(
nextItem.id,
DownloadStatus.completed,
progress: 1.0,
filePath: filePath,
);
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
id: nextItem.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
coverUrl: nextItem.track.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? nextItem.service,
downloadedAt: DateTime.now(),
),
);
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
print('[DownloadQueue] Download failed: $errorMsg');
updateItemStatus(
nextItem.id,
DownloadStatus.failed,
error: errorMsg,
);
}
} catch (e, stackTrace) {
_stopProgressPolling();
print('[DownloadQueue] Exception: $e');
print('[DownloadQueue] StackTrace: $stackTrace');
updateItemStatus(
nextItem.id,
DownloadStatus.failed,
error: e.toString(),
);
// Create the download future
final future = _downloadSingleItem(item).whenComplete(() {
activeDownloads.remove(item.id);
// Clear item progress after download completes
PlatformBridge.clearItemProgress(item.id).catchError((_) {});
});
activeDownloads[item.id] = future;
print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
}
// Wait for at least one download to complete before checking for more
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
}
}
// Wait for all remaining downloads to complete
if (activeDownloads.isNotEmpty) {
await Future.wait(activeDownloads.values);
}
}
_stopProgressPolling();
print('[DownloadQueue] Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
/// 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}');
// Only set currentDownload for sequential mode (for progress polling)
if (state.concurrentDownloads == 1) {
state = state.copyWith(currentDownload: item);
_startProgressPolling(item.id);
}
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);
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
print('[DownloadQueue] Quality: ${state.audioQuality}');
print('[DownloadQueue] Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
isrc: item.track.isrc ?? '',
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
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(
isrc: item.track.isrc ?? '',
service: item.service,
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
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,
);
}
// Stop progress polling for this item (sequential mode only)
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
print('[DownloadQueue] Result: $result');
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
print('[DownloadQueue] 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...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
print('[DownloadQueue] Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
item.track,
);
print('[DownloadQueue] Metadata and cover embedded successfully');
} catch (e) {
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
}
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
progress: 1.0,
filePath: filePath,
);
// Show completion notification for this track
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: state.completedCount,
totalCount: _totalQueuedAtStart,
);
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
// Additional metadata
isrc: item.track.isrc,
spotifyId: item.track.id,
trackNumber: item.track.trackNumber,
discNumber: item.track.discNumber,
duration: item.track.duration,
releaseDate: item.track.releaseDate,
quality: state.audioQuality,
),
);
// 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');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
);
}
// Increment download counter and cleanup connections periodically
_downloadCount++;
if (_downloadCount % _cleanupInterval == 0) {
print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Connection cleanup failed: $e');
}
}
} catch (e, stackTrace) {
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
print('[DownloadQueue] Exception: $e');
print('[DownloadQueue] StackTrace: $stackTrace');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: e.toString(),
);
}
}
}
+34
View File
@@ -64,6 +64,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(isFirstLaunch: false);
_saveSettings();
}
void setConcurrentDownloads(int count) {
// Clamp between 1 and 3
final clamped = count.clamp(1, 3);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
}
void setCheckForUpdates(bool enabled) {
state = state.copyWith(checkForUpdates: enabled);
_saveSettings();
}
void setHasSearchedBefore() {
if (!state.hasSearchedBefore) {
state = state.copyWith(hasSearchedBefore: true);
_saveSettings();
}
}
void setFolderOrganization(String organization) {
state = state.copyWith(folderOrganization: organization);
_saveSettings();
}
void setConvertLyricsToRomaji(bool enabled) {
state = state.copyWith(convertLyricsToRomaji: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+144 -14
View File
@@ -8,7 +8,10 @@ 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
const TrackState({
this.tracks = const [],
@@ -16,16 +19,27 @@ class TrackState {
this.error,
this.albumName,
this.playlistName,
this.artistName,
this.coverUrl,
this.artistAlbums,
this.previousState,
});
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,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
@@ -33,11 +47,35 @@ 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),
);
}
}
/// 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 +83,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 +105,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 +222,54 @@ class TrackNotifier extends Notifier<TrackState> {
state = const TrackState();
}
/// 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 +301,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>(
-372
View File
@@ -1,372 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class HistoryScreen extends ConsumerWidget {
const HistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyState = ref.watch(downloadHistoryProvider);
final history = historyState.items;
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Download History'),
actions: [
if (history.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => _showClearHistoryDialog(context, ref),
tooltip: 'Clear history',
),
],
),
body: history.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: history.length,
itemBuilder: (context, index) {
final item = history[index];
return _buildHistoryItem(context, ref, item, colorScheme);
},
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No download history',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Downloaded tracks will appear here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
final fileExists = File(item.filePath).existsSync();
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
color: colorScheme.error,
child: Icon(Icons.delete, color: colorScheme.onError),
),
onDismissed: (_) {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed "${item.trackName}" from history')),
);
},
child: ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
),
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Row(
children: [
Icon(
_getServiceIcon(item.service),
size: 12,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_formatDate(item.downloadedAt),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (!fileExists) ...[
const SizedBox(width: 8),
Icon(
Icons.warning,
size: 12,
color: colorScheme.error,
),
const SizedBox(width: 2),
Text(
'File missing',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
],
),
trailing: fileExists
? IconButton(
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
onPressed: () => _openFile(context, item.filePath),
)
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
),
);
}
IconData _getServiceIcon(String service) {
switch (service.toLowerCase()) {
case 'tidal':
return Icons.waves;
case 'qobuz':
return Icons.album;
case 'amazon':
return Icons.shopping_cart;
default:
return Icons.cloud_download;
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
if (diff.inHours == 0) {
return '${diff.inMinutes}m ago';
}
return '${diff.inHours}h ago';
} else if (diff.inDays == 1) {
return 'Yesterday';
} else if (diff.inDays < 7) {
return '${diff.inDays}d ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
Future<void> _openFile(BuildContext context, String filePath) async {
try {
final result = await OpenFilex.open(filePath);
if (result.type != ResultType.done) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cannot open: ${result.message}'),
action: SnackBarAction(
label: 'Copy Path',
onPressed: () {
Clipboard.setData(ClipboardData(text: filePath));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Path copied to clipboard')),
);
},
),
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 64,
height: 64,
fit: BoxFit.cover,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trackName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
item.artistName,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Text(
item.albumName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(),
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context);
},
icon: Icon(Icons.delete, color: colorScheme.error),
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
),
if (File(item.filePath).existsSync())
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_openFile(context, item.filePath);
},
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
),
],
),
],
),
),
);
}
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: isPath ? 12 : 14,
fontFamily: isPath ? 'monospace' : null,
),
),
),
],
),
);
}
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text(
'Are you sure you want to clear all download history? '
'This will not delete the downloaded files.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
-388
View File
@@ -1,388 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class HistoryTab extends ConsumerWidget {
const HistoryTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyState = ref.watch(downloadHistoryProvider);
final history = historyState.items;
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
// Header with clear action
if (history.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${history.length} downloads',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
TextButton.icon(
onPressed: () => _showClearHistoryDialog(context, ref),
icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error),
label: Text('Clear history', style: TextStyle(color: colorScheme.error)),
),
],
),
),
// History list
Expanded(
child: history.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: history.length,
itemBuilder: (context, index) {
final item = history[index];
return _buildHistoryItem(context, ref, item, colorScheme);
},
),
),
],
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No download history',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Downloaded tracks will appear here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
final fileExists = File(item.filePath).existsSync();
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
color: colorScheme.error,
child: Icon(Icons.delete, color: colorScheme.onError),
),
onDismissed: (_) {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed "${item.trackName}" from history')),
);
},
child: ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
),
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Row(
children: [
Icon(
_getServiceIcon(item.service),
size: 12,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_formatDate(item.downloadedAt),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (!fileExists) ...[
const SizedBox(width: 8),
Icon(
Icons.warning,
size: 12,
color: colorScheme.error,
),
const SizedBox(width: 2),
Text(
'File missing',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
],
),
trailing: fileExists
? IconButton(
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
onPressed: () => _openFile(context, item.filePath),
)
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
),
);
}
IconData _getServiceIcon(String service) {
switch (service.toLowerCase()) {
case 'tidal':
return Icons.waves;
case 'qobuz':
return Icons.album;
case 'amazon':
return Icons.shopping_cart;
default:
return Icons.cloud_download;
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
if (diff.inHours == 0) {
return '${diff.inMinutes}m ago';
}
return '${diff.inHours}h ago';
} else if (diff.inDays == 1) {
return 'Yesterday';
} else if (diff.inDays < 7) {
return '${diff.inDays}d ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
Future<void> _openFile(BuildContext context, String filePath) async {
try {
final result = await OpenFilex.open(filePath);
if (result.type != ResultType.done) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cannot open: ${result.message}'),
action: SnackBarAction(
label: 'Copy Path',
onPressed: () {
Clipboard.setData(ClipboardData(text: filePath));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Path copied to clipboard')),
);
},
),
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 64,
height: 64,
fit: BoxFit.cover,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trackName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
item.artistName,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Text(
item.albumName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(),
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context);
},
icon: Icon(Icons.delete, color: colorScheme.error),
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
),
if (File(item.filePath).existsSync())
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_openFile(context, item.filePath);
},
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
),
],
),
],
),
),
);
}
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: isPath ? 12 : 14,
fontFamily: isPath ? 'monospace' : null,
),
),
),
],
),
);
}
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text(
'Are you sure you want to clear all download history? '
'This will not delete the downloaded files.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
+3 -3
View File
@@ -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(
+437 -337
View File
@@ -1,48 +1,46 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
class HomeTab extends ConsumerStatefulWidget {
const HomeTab({super.key});
@override
ConsumerState<HomeTab> createState() => _HomeTabState();
}
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin {
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController();
@override
bool get wantKeepAlive => true;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
void dispose() { _urlController.dispose(); super.dispose(); }
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!;
}
Future<void> _clearAndRefresh() async {
_urlController.clear();
ref.read(trackProvider.notifier).clear();
await Future.delayed(const Duration(milliseconds: 300));
}
Future<void> _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
await ref.read(trackProvider.notifier).search(url);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
void _downloadTrack(int index) {
@@ -51,181 +49,343 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
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')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
void _downloadAll() {
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')),
);
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')),
);
}
}
bool get _hasResults {
final trackState = ref.watch(trackProvider);
return trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading;
}
@override
Widget build(BuildContext context) {
super.build(context);
final trackState = ref.watch(trackProvider);
final historyState = ref.watch(downloadHistoryProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasResults = _hasResults;
return CustomScrollView(
slivers: [
// Search bar
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _urlController,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
prefixIcon: const Icon(Icons.link),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
],
return Scaffold(
body: SafeArea(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: hasResults
? _buildResultsView(trackState, colorScheme)
: _buildCenteredSearch(colorScheme),
),
),
);
}
// Centered search view when no results
Widget _buildCenteredSearch(ColorScheme colorScheme) {
final historyItems = ref.watch(downloadHistoryProvider).items;
return Center(
key: const ValueKey('centered'),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App icon/logo
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
),
const SizedBox(height: 24),
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,
),
),
const SizedBox(height: 32),
// Search bar
_buildSearchBar(colorScheme),
const SizedBox(height: 12),
// Helper text
if (!ref.watch(settingsProvider).hasSearchedBefore)
Text(
'Supports: Track, Album, Playlist, Artist URLs',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onSubmitted: (_) => _fetchMetadata(),
// Recent downloads - compact horizontal scroll
if (historyItems.isNotEmpty) ...[
const SizedBox(height: 32),
_buildRecentDownloads(historyItems, colorScheme),
],
],
),
),
);
}
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,
),
),
),
// Error message
if (trackState.error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
),
),
// Loading indicator
if (trackState.isLoading)
const SliverToBoxAdapter(
child: LinearProgressIndicator(),
),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
// Download All button (when no header)
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
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),
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,
),
],
),
),
),
),
);
},
),
// Track list
if (trackState.tracks.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackTile(index, colorScheme),
childCount: trackState.tracks.length,
),
),
// Divider between search results and history
if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty)
const SliverToBoxAdapter(
child: Divider(height: 32),
),
// Recent Downloads section 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.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () => _showClearHistoryDialog(colorScheme),
child: const Text('Clear'),
),
],
),
),
),
// Recent Downloads list
if (historyState.items.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
childCount: historyState.items.length > 5 ? 5 : historyState.items.length,
),
),
// Show more history button
if (historyState.items.length > 5)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton(
onPressed: () => _showAllHistory(colorScheme),
child: Text('Show all ${historyState.items.length} downloads'),
),
),
),
// Empty state (when no tracks and no history)
if (trackState.tracks.isEmpty && historyState.items.isEmpty)
SliverFillRemaining(
child: _buildEmptyState(colorScheme),
),
// Bottom padding
const SliverToBoxAdapter(
child: SizedBox(height: 16),
),
],
);
}
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 view with search bar at top
Widget _buildResultsView(TrackState trackState, ColorScheme colorScheme) {
return RefreshIndicator(
key: const ValueKey('results'),
onRefresh: _clearAndRefresh,
displacement: 100,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Collapsing App Bar
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(
'Search',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
// Search bar at top
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
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)),
// 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) {
return 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.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.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),
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20),
),
onPressed: _fetchMetadata,
tooltip: 'Search',
),
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
onSubmitted: (_) => _fetchMetadata(),
);
}
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
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(12),
child: CachedNetworkImage(imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover,
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 ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text('${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
FilledButton.tonal(onPressed: _downloadAll,
style: FilledButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(16)),
child: const Icon(Icons.download)),
],
),
),
);
}
Widget _buildArtistHeader(TrackState state, ColorScheme colorScheme) {
final albumCount = state.artistAlbums?.length ?? 0;
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
@@ -234,16 +394,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
children: [
if (state.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(40),
child: CachedNetworkImage(
imageUrl: state.coverUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, color: colorScheme.onSurfaceVariant),
),
),
),
@@ -253,205 +414,144 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.albumName ?? state.playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
state.artistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
'$albumCount releases',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
// Download all button
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.download),
),
],
),
),
);
}
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(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
? ClipRRect(borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
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(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: IconButton(
icon: Icon(Icons.download, color: colorScheme.primary),
onPressed: () => _downloadTrack(index),
),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)),
onTap: () => _downloadTrack(index),
);
}
Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) {
final fileExists = File(item.filePath).existsSync();
return ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.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: fileExists ? () => _openFile(item.filePath) : null,
);
}
Widget _buildEmptyState(ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Paste a Spotify URL to get started',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
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) => _buildHistoryTile(
historyState.items[index],
colorScheme,
),
),
),
],
),
),
);
}
}
+161 -60
View File
@@ -1,9 +1,16 @@
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_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';
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@@ -15,90 +22,184 @@ class MainShell extends ConsumerStatefulWidget {
class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
// Check for updates after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
_setupShareListener();
});
}
void _setupShareListener() {
// Check for pending URL that was received before listener was ready
final pendingUrl = ShareIntentService().consumePendingUrl();
if (pendingUrl != null) {
print('[MainShell] Processing pending shared URL: $pendingUrl');
_handleSharedUrl(pendingUrl);
}
// Listen for future shared URLs
_shareSubscription = ShareIntentService().sharedUrlStream.listen((url) {
print('[MainShell] 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;
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
final updateInfo = await UpdateChecker.checkForUpdate();
if (updateInfo != null && mounted) {
showUpdateDialog(
context,
updateInfo: updateInfo,
onDisableUpdates: () {
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
},
);
}
}
@override
void dispose() {
_shareSubscription?.cancel();
_pageController.dispose();
super.dispose();
}
void _onNavTap(int index) {
setState(() => _currentIndex = index);
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
if (_currentIndex != index) {
setState(() => _currentIndex = index);
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
}
void _onPageChanged(int index) {
setState(() => _currentIndex = index);
if (_currentIndex != index) {
setState(() => _currentIndex = index);
}
}
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);
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider);
return Scaffold(
appBar: AppBar(
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/images/logo.png',
width: 40,
height: 40,
),
),
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 not on Search tab, go to Search tab first
if (_currentIndex != 0) {
_onNavTap(0);
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(),
],
),
title: const Text('SpotiFLAC'),
),
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const BouncingScrollPhysics(),
children: const [
HomeTab(),
QueueTab(),
SettingsTab(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 300),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
child: const Icon(Icons.download_outlined),
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.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
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',
),
],
),
),
);
}
+600 -200
View File
@@ -1,250 +1,650 @@
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 Column(
children: [
// Header with actions
if (queueState.items.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${queueState.items.length} items',
style: Theme.of(context).textTheme.titleSmall?.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)),
),
],
),
],
return 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(
'History',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
// Queue list
Expanded(
child: queueState.items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: queueState.items.length,
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
),
),
// Pause/Resume controls - only show when multiple items or paused
if ((queueState.isProcessing || queueState.queuedCount > 0) && (queueState.items.length > 1 || queueState.isPaused))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Status icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: queueState.isPaused
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
queueState.isPaused ? Icons.pause : Icons.downloading,
color: queueState.isPaused
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 12),
// Status text - simplified
Expanded(
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: Text(queueState.isPaused ? 'Resume' : 'Pause'),
),
],
),
),
),
),
),
// Queue header
if (queueState.items.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
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, queueState.items[index], colorScheme),
childCount: queueState.items.length,
)),
// 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 SliverToBoxAdapter(child: SizedBox(height: 16)),
],
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue_music,
size: 64,
color: colorScheme.onSurfaceVariant,
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.history, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('No download history', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
Text('Downloaded tracks will appear here', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
]),
);
Widget _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
final isCompleted = item.status == DownloadStatus.completed;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell(
onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Cover art with Hero for completed items
isCompleted
? Hero(
tag: 'cover_${item.id}',
child: _buildCoverArt(item, colorScheme),
)
: _buildCoverArt(item, colorScheme),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
minHeight: 6,
),
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
],
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
Text(
item.error ?? 'Download failed',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
),
const SizedBox(width: 8),
// Action buttons based on status
_buildActionButtons(context, item, colorScheme),
],
),
const SizedBox(height: 16),
Text(
'No downloads in queue',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add tracks from the Home tab',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
);
}
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
return ListTile(
leading: item.track.coverUrl != null
? ClipRRect(
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: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
],
],
),
trailing: _buildStatusIcon(context, item, colorScheme),
onTap: item.status == DownloadStatus.queued
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
: null,
);
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
}
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
switch (item.status) {
case DownloadStatus.queued:
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
// Queued: Show cancel button
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.close, color: colorScheme.error),
tooltip: 'Cancel',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
),
],
);
case DownloadStatus.downloading:
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: item.progress,
strokeWidth: 2,
color: colorScheme.primary,
),
// Downloading: Show stop button
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.stop, color: colorScheme.error),
tooltip: 'Stop',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
),
],
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
return IconButton(
icon: Icon(Icons.error, color: colorScheme.error),
onPressed: () => _showErrorDialog(context, item, colorScheme),
tooltip: 'Tap to see error details',
// 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:
// Failed: Show retry and remove buttons
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
icon: Icon(Icons.refresh, color: colorScheme.primary),
tooltip: 'Retry',
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
const SizedBox(width: 4),
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
icon: Icon(Icons.close, color: colorScheme.error),
tooltip: 'Remove',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
),
],
);
case DownloadStatus.skipped:
return Icon(Icons.skip_next, color: colorScheme.primary);
// Skipped: Show retry and remove buttons
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
icon: Icon(Icons.refresh, color: colorScheme.primary),
tooltip: 'Retry',
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
const SizedBox(width: 4),
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
tooltip: 'Remove',
),
],
);
}
}
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
const Text('Download Failed'),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
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: [
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Artist: ${item.track.artistName}'),
const SizedBox(height: 16),
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
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),
),
),
child: Text(
item.error ?? 'Unknown error',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.onErrorContainer,
),
// 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),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All'),
content: const Text('Are you sure you want to clear all downloads?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
class AboutPage extends StatelessWidget {
const AboutPage({super.key});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('About',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// App info card
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
),
),
const SizedBox(width: 16),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
),
]),
]),
),
),
),
),
// GitHub section
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
SliverList(delegate: SliverChildListDelegate([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
trailing: const Icon(Icons.open_in_new, size: 20),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
title: Text('Original ${AppInfo.appName}'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
trailing: const Icon(Icons.open_in_new, size: 20),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
])),
// Credits section
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(children: [
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
const SizedBox(height: 12),
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
]),
),
),
// Copyright
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(child: Text(AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
),
),
],
),
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
);
}
class _CreditRow extends StatelessWidget {
final String label;
final String value;
const _CreditRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
]);
}
}
@@ -0,0 +1,283 @@
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';
class AppearanceSettingsPage extends ConsumerWidget {
const AppearanceSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeSettings = ref.watch(themeProvider);
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('Appearance',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// Theme section
SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
),
),
// Color section
SliverToBoxAdapter(child: _SectionHeader(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),
),
),
if (!themeSettings.useDynamicColor)
SliverToBoxAdapter(
child: _ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
),
),
// Layout section
SliverToBoxAdapter(child: _SectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
),
),
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
),
);
}
}
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;
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
@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)),
]),
),
);
}
}
class _ThemeModeChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
),
),
),
);
}
}
class _ColorPicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPicker({required this.currentColor, required this.onColorSelected});
static const _colors = [
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () => onColorSelected(color),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 44, height: 44,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
),
);
}).toList()),
]),
);
}
}
class _HistoryViewSelector extends StatelessWidget {
final String currentMode;
final ValueChanged<String> onChanged;
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text('History View', style: Theme.of(context).textTheme.bodySmall?.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;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
),
),
),
);
}
}
@@ -0,0 +1,335 @@
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';
class DownloadSettingsPage extends ConsumerWidget {
const DownloadSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('Download',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// Service section
SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _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')),
])),
// 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),
),
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),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.create_new_folder_outlined, color: colorScheme.onSurfaceVariant),
title: const Text('Folder Organization'),
subtitle: Text(_getFolderOrganizationLabel(settings.folderOrganization)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
),
])),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context, isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
const SizedBox(height: 16),
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 24),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
]),
]),
),
);
}
Future<void> _pickDirectory(WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
String _getFolderOrganizationLabel(String value) {
switch (value) {
case 'artist':
return 'By Artist (Artist/Track.flac)';
case 'album':
return 'By Album (Album/Track.flac)';
case 'artist_album':
return 'By Artist & Album (Artist/Album/Track.flac)';
default:
return 'None (all in one folder)';
}
}
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 _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 _ServiceSelector extends StatelessWidget {
final String currentService;
final ValueChanged<String> onChanged;
const _ServiceSelector({required this.currentService, required this.onChanged});
@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')),
]),
),
);
}
}
class _ServiceChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
),
),
),
);
}
}
class _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});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: onTap,
);
}
}
class _FolderOption extends StatelessWidget {
final String title;
final String subtitle;
final String example;
final bool isSelected;
final VoidCallback onTap;
const _FolderOption({required this.title, required this.subtitle, required this.example, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
title: Text(title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(subtitle),
const SizedBox(height: 4),
Text(example, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: colorScheme.primary)),
],
),
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: onTap,
);
}
}
@@ -0,0 +1,243 @@
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';
class OptionsSettingsPage extends ConsumerWidget {
const OptionsSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('Options',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// 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),
),
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')),
SliverToBoxAdapter(
child: _ConcurrentDownloadsSelector(
currentValue: settings.concurrentDownloads,
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
),
),
// Lyrics section
SliverToBoxAdapter(child: _SectionHeader(title: 'Lyrics')),
SliverList(delegate: SliverChildListDelegate([
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: Icon(Icons.translate, color: colorScheme.onSurfaceVariant),
title: const Text('Convert Japanese to Romaji'),
subtitle: const Text('Auto-convert Hiragana/Katakana lyrics'),
value: settings.convertLyricsToRomaji,
onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v),
),
])),
// App section
SliverToBoxAdapter(child: _SectionHeader(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),
),
),
// Data section
SliverToBoxAdapter(child: _SectionHeader(title: 'Data')),
SliverToBoxAdapter(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.delete_forever, color: colorScheme.error),
title: const Text('Clear Download History'),
subtitle: const Text('Remove all downloaded tracks from history'),
onTap: () => _showClearHistoryDialog(context, ref, colorScheme),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
void _showClearHistoryDialog(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text('Are you sure you want to clear all download history? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('History cleared')),
);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
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 {
final int currentValue;
final ValueChanged<int> onChanged;
const _ConcurrentDownloadsSelector({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),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Concurrent Downloads'),
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
]),
const SizedBox(height: 16),
Row(children: [
_ConcurrentChip(label: '1', isSelected: currentValue == 1, onTap: () => onChanged(1)),
const SizedBox(width: 8),
_ConcurrentChip(label: '2', isSelected: currentValue == 2, onTap: () => onChanged(2)),
const SizedBox(width: 8),
_ConcurrentChip(label: '3', isSelected: currentValue == 3, onTap: () => onChanged(3)),
]),
const SizedBox(height: 12),
Row(children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text('Parallel downloads may trigger rate limiting',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.error))),
]),
]),
);
}
}
class _ConcurrentChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ConcurrentChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
),
),
),
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return CustomScrollView(
slivers: [
// Collapsing App Bar - 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(
'Settings',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
// Menu items
SliverList(delegate: SliverChildListDelegate([
_SettingsMenuItem(
icon: Icons.palette_outlined,
title: 'Appearance',
subtitle: 'Theme, colors, display',
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
),
_SettingsMenuItem(
icon: Icons.download_outlined,
title: 'Download',
subtitle: 'Service, quality, filename format',
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
_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
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
);
}
void _navigateTo(BuildContext context, Widget page) {
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),
]),
),
);
}
}
+157 -55
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
@@ -54,9 +56,6 @@ class SettingsScreen extends ConsumerWidget {
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
// Theme Preview
_buildThemePreview(context, colorScheme),
const Divider(),
// Download Section
@@ -125,6 +124,54 @@ class SettingsScreen extends ConsumerWidget {
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
@@ -132,19 +179,64 @@ class SettingsScreen extends ConsumerWidget {
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: const Text('SpotiFLAC v1.0.1'),
onTap: () => showAboutDialog(
context: context,
applicationName: 'SpotiFLAC',
applicationVersion: '1.0.1',
applicationLegalese: '© 2024 SpotiFLAC',
),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
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)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -158,51 +250,6 @@ class SettingsScreen extends ConsumerWidget {
);
}
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme Preview',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
],
),
],
),
),
),
);
}
Widget _buildColorChip(String label, Color background, Color foreground) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(16),
),
child: Text(
label,
style: TextStyle(color: foreground, fontSize: 12),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
@@ -423,4 +470,59 @@ class SettingsScreen extends ConsumerWidget {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+162 -49
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
@@ -61,9 +63,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
// Theme Preview
_buildThemePreview(context, colorScheme),
const Divider(),
// Download Section
@@ -132,6 +131,54 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
@@ -139,13 +186,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: const Text('SpotiFLAC v1.0.1'),
onTap: () => showAboutDialog(
context: context,
applicationName: 'SpotiFLAC',
applicationVersion: '1.0.1',
applicationLegalese: '© 2024 SpotiFLAC',
),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
// Bottom padding for navigation bar
@@ -154,6 +196,56 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
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)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -167,42 +259,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
);
}
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Theme Preview', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
],
),
],
),
),
),
);
}
Widget _buildColorChip(String label, Color background, Color foreground) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
child: Text(label, style: TextStyle(color: foreground, fontSize: 12)),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
@@ -222,8 +278,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (Lossless)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
default: return quality;
}
}
@@ -334,7 +391,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
mainAxisSize: MainAxisSize.min,
children: [
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
@@ -392,4 +450,59 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+210 -168
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -17,11 +18,15 @@ class SetupScreen extends ConsumerStatefulWidget {
class _SetupScreenState extends ConsumerState<SetupScreen> {
int _currentStep = 0;
bool _permissionGranted = false;
bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false;
String? _selectedDirectory;
bool _isLoading = false;
int _androidSdkVersion = 0;
// Total steps: Storage -> Notification (Android 13+) -> Folder
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
@override
void initState() {
super.initState();
@@ -35,47 +40,54 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_androidSdkVersion = androidInfo.version.sdkInt;
debugPrint('Android SDK Version: $_androidSdkVersion');
}
await _checkInitialPermission();
await _checkInitialPermissions();
}
Future<void> _checkInitialPermission() async {
Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) {
// iOS doesn't need storage permission - app uses its own Documents directory
if (mounted) {
setState(() => _permissionGranted = true);
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
});
}
} else if (Platform.isAndroid) {
PermissionStatus status;
// Check storage permission
PermissionStatus storageStatus;
if (_androidSdkVersion >= 33) {
status = await Permission.audio.status;
storageStatus = await Permission.audio.status;
} else if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.status;
storageStatus = await Permission.manageExternalStorage.status;
} else {
status = await Permission.storage.status;
storageStatus = await Permission.storage.status;
}
if (status.isGranted && mounted) {
setState(() => _permissionGranted = true);
// Check notification permission (Android 13+)
PermissionStatus notificationStatus = PermissionStatus.granted;
if (_androidSdkVersion >= 33) {
notificationStatus = await Permission.notification.status;
}
if (mounted) {
setState(() {
_storagePermissionGranted = storageStatus.isGranted;
_notificationPermissionGranted = notificationStatus.isGranted;
});
}
}
}
Future<void> _requestPermission() async {
Future<void> _requestStoragePermission() async {
setState(() => _isLoading = true);
try {
if (Platform.isIOS) {
// iOS doesn't need storage permission - app uses its own Documents directory
setState(() => _permissionGranted = true);
setState(() => _storagePermissionGranted = true);
} else if (Platform.isAndroid) {
PermissionStatus status;
if (_androidSdkVersion >= 33) {
status = await Permission.audio.request();
if (!status.isGranted) {
await Permission.notification.request();
}
} else if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
@@ -83,15 +95,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
if (status.isGranted) {
setState(() => _permissionGranted = true);
setState(() => _storagePermissionGranted = true);
} else if (status.isPermanentlyDenied) {
_showPermissionDeniedDialog();
_showPermissionDeniedDialog('Storage');
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Permission denied. Please grant permission to continue.'),
),
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
);
}
}
@@ -99,22 +109,46 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} catch (e) {
debugPrint('Permission error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
setState(() => _isLoading = false);
}
}
void _showPermissionDeniedDialog() {
Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true);
try {
if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
_showPermissionDeniedDialog('Notification');
}
} else {
// Notification permission not needed for older Android
setState(() => _notificationPermissionGranted = true);
}
} catch (e) {
debugPrint('Notification permission error: $e');
} finally {
setState(() => _isLoading = false);
}
}
void _skipNotificationPermission() {
setState(() => _notificationPermissionGranted = true);
}
void _showPermissionDeniedDialog(String permissionType) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission Required'),
content: const Text(
'Storage permission is required to save downloaded music files. '
title: Text('$permissionType Permission Required'),
content: Text(
'$permissionType permission is required for the best experience. '
'Please grant permission in app settings.',
),
actions: [
@@ -151,18 +185,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text(
'No folder selected. Would you like to use the default Music folder?\n\n$defaultDir',
),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Use Default'),
),
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
@@ -179,7 +205,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<String> _getDefaultDirectory() async {
if (Platform.isIOS) {
// iOS: Use Documents directory (accessible via Files app)
final appDir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${appDir.path}/SpotiFLAC');
try {
@@ -225,9 +250,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
setState(() => _isLoading = false);
@@ -244,9 +267,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
minHeight: math.max(0, MediaQuery.of(context).size.height -
MediaQuery.of(context).padding.top -
MediaQuery.of(context).padding.bottom - 48,
MediaQuery.of(context).padding.bottom - 48),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -257,27 +280,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(height: 24),
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
),
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
),
const SizedBox(height: 12),
Text(
'SpotiFLAC',
Text('SpotiFLAC',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
fontWeight: FontWeight.bold, color: colorScheme.primary)),
const SizedBox(height: 4),
Text(
'Download Spotify tracks in FLAC',
Text('Download Spotify tracks in FLAC',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
color: colorScheme.onSurfaceVariant)),
],
),
@@ -287,9 +299,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(height: 24),
_buildStepIndicator(colorScheme),
const SizedBox(height: 24),
_currentStep == 0
? _buildPermissionStep(colorScheme)
: _buildDirectoryStep(colorScheme),
_buildCurrentStepContent(colorScheme),
],
),
@@ -310,24 +320,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Widget _buildStepIndicator(ColorScheme colorScheme) {
final steps = _androidSdkVersion >= 33
? ['Storage', 'Notification', 'Folder']
: ['Permission', 'Folder'];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStepDot(0, 'Permission', colorScheme),
Container(
width: 40,
height: 2,
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
),
_buildStepDot(1, 'Folder', colorScheme),
for (int i = 0; i < steps.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Container(
width: 32,
height: 2,
color: _currentStep >= i ? colorScheme.primary : colorScheme.surfaceContainerHighest,
),
),
_buildStepDot(i, steps[i], colorScheme),
],
],
);
}
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
final isActive = _currentStep >= step;
final isCompleted = (step == 0 && _permissionGranted) ||
(step == 1 && _selectedDirectory != null);
final isCompleted = _isStepCompleted(step);
return Column(
children: [
@@ -338,86 +356,143 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
shape: BoxShape.circle,
color: isCompleted
? colorScheme.primary
: isActive
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
),
child: Center(
child: isCompleted
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
: Text(
'${step + 1}',
: Text('${step + 1}',
style: TextStyle(
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 4),
Text(
label,
Text(label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
),
),
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
],
);
}
Widget _buildPermissionStep(ColorScheme colorScheme) {
bool _isStepCompleted(int step) {
if (_androidSdkVersion >= 33) {
// 3 steps: Storage, Notification, Folder
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _notificationPermissionGranted;
case 2: return _selectedDirectory != null;
}
} else {
// 2 steps: Permission, Folder
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null;
}
}
return false;
}
Widget _buildCurrentStepContent(ColorScheme colorScheme) {
if (_androidSdkVersion >= 33) {
switch (_currentStep) {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildNotificationPermissionStep(colorScheme);
case 2: return _buildDirectoryStep(colorScheme);
}
} else {
switch (_currentStep) {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildDirectoryStep(colorScheme);
}
}
return const SizedBox();
}
Widget _buildStoragePermissionStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_permissionGranted ? Icons.check_circle : Icons.folder_open,
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
size: 56,
color: _permissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
_permissionGranted
? 'Storage Permission Granted!'
: 'Storage Permission Required',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_permissionGranted
? 'You can now select where to save your music files.'
_storagePermissionGranted
? 'You can now proceed to the next step.'
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
if (!_permissionGranted)
if (!_storagePermissionGranted)
FilledButton.icon(
onPressed: _isLoading ? null : _requestPermission,
onPressed: _isLoading ? null : _requestStoragePermission,
icon: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.security),
label: const Text('Grant Permission'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
],
);
}
Widget _buildNotificationPermissionStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
size: 56,
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
if (!_notificationPermissionGranted) ...[
FilledButton.icon(
onPressed: _isLoading ? null : _requestNotificationPermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.notifications_active),
label: const Text('Enable Notifications'),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
const SizedBox(height: 12),
TextButton(
onPressed: _skipNotificationPermission,
child: const Text('Skip for now'),
),
],
],
);
}
Widget _buildDirectoryStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -430,12 +505,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 16),
Text(
_selectedDirectory != null
? 'Download Folder Selected!'
: 'Choose Download Folder',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
@@ -452,46 +523,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Icon(Icons.folder, color: colorScheme.primary, size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
_selectedDirectory!,
child: Text(_selectedDirectory!,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
overflow: TextOverflow.ellipsis),
),
],
),
)
else
Text(
'Select a folder where your downloaded music will be saved.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
Text('Select a folder where your downloaded music will be saved.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _isLoading ? null : _selectDirectory,
icon: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
],
);
}
Widget _buildNavigationButtons(ColorScheme colorScheme) {
final isLastStep = _currentStep == _totalSteps - 1;
final canProceed = _isStepCompleted(_currentStep);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -506,41 +566,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(width: 100),
// Next/Finish button
if (_currentStep == 0)
if (!isLastStep)
FilledButton(
onPressed: _permissionGranted
? () => setState(() => _currentStep++)
: null,
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Next'),
SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 18),
],
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
),
)
else
FilledButton(
onPressed: _selectedDirectory != null && !_isLoading
? _completeSetup
: null,
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Get Started'),
SizedBox(width: 8),
Icon(Icons.check, size: 18),
],
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
),
),
],
+976
View File
@@ -0,0 +1,976 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:url_launcher/url_launcher.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
class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem item;
const TrackMetadataScreen({super.key, required this.item});
@override
ConsumerState<TrackMetadataScreen> createState() => _TrackMetadataScreenState();
}
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false;
int? _fileSize;
String? _lyrics;
bool _lyricsLoading = false;
String? _lyricsError;
@override
void initState() {
super.initState();
_checkFile();
}
Future<void> _checkFile() async {
final file = File(widget.item.filePath);
final exists = await file.exists();
int? size;
if (exists) {
try {
size = await file.length();
} catch (_) {}
}
if (mounted) {
setState(() {
_fileExists = exists;
_fileSize = size;
});
}
}
DownloadHistoryItem get item => widget.item;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar with cover art background
SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
flexibleSpace: FlexibleSpaceBar(
background: _buildHeaderBackground(context, colorScheme),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.more_vert, color: colorScheme.onSurface),
),
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
),
],
),
// Content
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track info card
_buildTrackInfoCard(context, colorScheme, _fileExists),
const SizedBox(height: 16),
// Metadata card
_buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
// File info card
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
const SizedBox(height: 16),
// Lyrics card
_buildLyricsCard(context, colorScheme),
const SizedBox(height: 24),
// Action buttons
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
],
),
),
),
],
),
);
}
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) {
return Stack(
fit: StackFit.expand,
children: [
// Blurred background
if (item.coverUrl != null)
CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
),
// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
// Cover art centered
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Hero(
tag: 'cover_${item.id}',
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
),
],
);
}
Widget _buildTrackInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists) {
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: [
// Track name
Text(
item.trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
// Artist name
Text(
item.artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(height: 8),
// Album name
Row(
children: [
Icon(
Icons.album,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.albumName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
// File status
if (!fileExists) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_rounded,
size: 16,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 6),
Text(
'File not found',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
],
),
),
);
}
Widget _buildMetadataCard(BuildContext context, ColorScheme colorScheme, int? fileSize) {
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.info_outline,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Metadata',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 16),
// Metadata grid
_buildMetadataGrid(context, colorScheme),
// Spotify link button
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _openSpotifyUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Open in Spotify'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
],
),
),
);
}
Future<void> _openSpotifyUrl(BuildContext context) async {
if (item.spotifyId == null) return;
final url = 'https://open.spotify.com/track/${item.spotifyId}';
try {
// Try to open in Spotify app first, fallback to browser
final uri = Uri.parse('spotify:track:${item.spotifyId}');
// ignore: deprecated_member_use
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
} catch (e) {
if (context.mounted) {
_copyToClipboard(context, url);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify URL copied to clipboard')),
);
}
}
}
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
final items = <_MetadataItem>[
_MetadataItem('Track name', item.trackName),
_MetadataItem('Artist', item.artistName),
if (item.albumArtist != null && item.albumArtist != item.artistName)
_MetadataItem('Album artist', item.albumArtist!),
_MetadataItem('Album', item.albumName),
if (item.trackNumber != null)
_MetadataItem('Track number', item.trackNumber.toString()),
if (item.discNumber != null && item.discNumber! > 1)
_MetadataItem('Disc number', item.discNumber.toString()),
if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)),
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
_MetadataItem('Release date', item.releaseDate!),
if (item.isrc != null && item.isrc!.isNotEmpty)
_MetadataItem('ISRC', item.isrc!),
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
_MetadataItem('Spotify ID', item.spotifyId!),
if (item.quality != null && item.quality!.isNotEmpty)
_MetadataItem('Quality', _formatQuality(item.quality!)),
_MetadataItem('Service', item.service.toUpperCase()),
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
];
return Column(
children: items.map((metadata) {
final isCopyable = metadata.label == 'ISRC' ||
metadata.label == 'Spotify ID';
return InkWell(
onTap: isCopyable ? () => _copyToClipboard(context, metadata.value) : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
metadata.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
metadata.value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
if (isCopyable)
Icon(
Icons.copy,
size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
],
),
),
);
}).toList(),
);
}
String _formatDuration(int seconds) {
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '$minutes:${secs.toString().padLeft(2, '0')}';
}
String _formatQuality(String quality) {
switch (quality) {
case 'LOSSLESS':
return 'Lossless (16-bit)';
case 'HI_RES':
return 'Hi-Res (24-bit)';
case 'HI_RES_LOSSLESS':
return 'Hi-Res Lossless (24-bit)';
default:
return quality;
}
}
String _formatQualityShort(String quality) {
switch (quality) {
case 'LOSSLESS':
return '16-bit';
case 'HI_RES':
return '24-bit';
case 'HI_RES_LOSSLESS':
return 'Hi-Res';
default:
return quality;
}
}
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
final fileName = item.filePath.split(Platform.pathSeparator).last;
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
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.folder_outlined,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'File Info',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 16),
// Format chip
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
fileExtension,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (fileSize != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_formatFileSize(fileSize),
style: TextStyle(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
if (item.quality != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_formatQualityShort(item.quality!),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getServiceColor(item.service, colorScheme),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getServiceIcon(item.service),
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
item.service.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// File path
InkWell(
onTap: () => _copyToClipboard(context, item.filePath),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Text(
item.filePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(
Icons.copy,
size: 18,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
],
),
),
);
}
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: [
// Play button
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: fileExists ? () => _openFile(context, item.filePath) : null,
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
const SizedBox(width: 12),
// Delete button
Expanded(
child: OutlinedButton.icon(
onPressed: () => _confirmDelete(context, ref, colorScheme),
icon: Icon(Icons.delete_outline, color: colorScheme.error),
label: Text('Delete', style: TextStyle(color: colorScheme.error)),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
side: BorderSide(color: colorScheme.error.withValues(alpha: 0.5)),
),
),
),
],
);
}
void _showOptionsMenu(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy file path'),
onTap: () {
Navigator.pop(context);
_copyToClipboard(context, item.filePath);
},
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share'),
onTap: () {
Navigator.pop(context);
// TODO: Implement share
},
),
ListTile(
leading: Icon(Icons.delete, color: colorScheme.error),
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, ref, colorScheme);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove from history?'),
content: const Text(
'This will remove the track from your download history. '
'The downloaded file will not be deleted.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Go back to history
},
child: Text('Remove', style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
Future<void> _openFile(BuildContext context, String filePath) async {
try {
final result = await OpenFilex.open(filePath);
if (result.type != ResultType.done && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open: ${result.message}')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _copyToClipboard(BuildContext context, String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied to clipboard'),
duration: Duration(seconds: 2),
),
);
}
String _formatFullDate(DateTime date) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${date.day} ${months[date.month - 1]} ${date.year}, '
'${date.hour.toString().padLeft(2, '0')}:'
'${date.minute.toString().padLeft(2, '0')}';
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
IconData _getServiceIcon(String service) {
switch (service.toLowerCase()) {
case 'tidal':
return Icons.waves;
case 'qobuz':
return Icons.album;
case 'amazon':
return Icons.shopping_cart;
default:
return Icons.cloud_download;
}
}
Color _getServiceColor(String service, ColorScheme colorScheme) {
switch (service.toLowerCase()) {
case 'tidal':
return const Color(0xFF0077B5); // Tidal blue (darker, more readable)
case 'qobuz':
return const Color(0xFF0052CC); // Qobuz blue
case 'amazon':
return const Color(0xFFFF9900); // Amazon orange
default:
return colorScheme.primary;
}
}
}
class _MetadataItem {
final String label;
final String value;
_MetadataItem(this.label, this.value);
}
+69
View File
@@ -0,0 +1,69 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:open_filex/open_filex.dart';
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) {
print('[ApkDownloader] Failed to download: ${response.statusCode}');
return null;
}
final contentLength = response.contentLength ?? 0;
// Get download directory
final dir = await getExternalStorageDirectory();
if (dir == null) {
print('[ApkDownloader] 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();
print('[ApkDownloader] Downloaded to: $filePath');
return filePath;
} catch (e) {
print('[ApkDownloader] Error: $e');
return null;
}
}
static Future<void> installApk(String filePath) async {
try {
final result = await OpenFilex.open(filePath);
print('[ApkDownloader] Open result: ${result.type} - ${result.message}');
} catch (e) {
print('[ApkDownloader] Install error: $e');
}
}
}
+303
View File
@@ -0,0 +1,303 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
static const int downloadProgressId = 1;
static const int updateDownloadId = 2;
static const String channelId = 'download_progress';
static const String channelName = 'Download Progress';
static const String channelDescription = 'Shows download progress for tracks';
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: false,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _notifications.initialize(initSettings);
// Create notification channel for Android
if (Platform.isAndroid) {
await _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
}
_isInitialized = true;
}
Future<void> showDownloadProgress({
required String trackName,
required String artistName,
required int progress,
required int total,
}) async {
if (!_isInitialized) await initialize();
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
downloadProgressId,
'Downloading $trackName',
'$artistName$percentage%',
details,
);
}
Future<void> showDownloadComplete({
required String trackName,
required String artistName,
int? completedCount,
int? totalCount,
}) async {
if (!_isInitialized) await initialize();
final title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
downloadProgressId,
title,
'$trackName - $artistName',
details,
);
}
Future<void> showQueueComplete({
required int completedCount,
required int failedCount,
}) async {
if (!_isInitialized) await initialize();
final title = failedCount > 0
? 'Downloads Finished ($completedCount done, $failedCount failed)'
: 'All Downloads Complete';
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
downloadProgressId,
title,
'$completedCount tracks downloaded successfully',
details,
);
}
Future<void> cancelDownloadNotification() async {
await _notifications.cancel(downloadProgressId);
}
// Update APK download notifications
Future<void> showUpdateDownloadProgress({
required String version,
required int received,
required int total,
}) async {
if (!_isInitialized) await initialize();
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
updateDownloadId,
'Downloading SpotiFLAC v$version',
'$receivedMB / $totalMB MB • $percentage%',
details,
);
}
Future<void> showUpdateDownloadComplete({required String version}) async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
updateDownloadId,
'Update Ready',
'SpotiFLAC v$version downloaded. Tap to install.',
details,
);
}
Future<void> showUpdateDownloadFailed() async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
updateDownloadId,
'Update Failed',
'Could not download update. Try again later.',
details,
);
}
Future<void> cancelUpdateNotification() async {
await _notifications.cancel(updateDownloadId);
}
}
+40 -1
View File
@@ -47,12 +47,15 @@ class PlatformBridge {
String? coverUrl,
required String outputDir,
required String filenameFormat,
String quality = 'LOSSLESS',
bool embedLyrics = true,
bool embedMaxQualityCover = true,
bool convertLyricsToRomaji = false,
int trackNumber = 1,
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String? itemId,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -65,12 +68,15 @@ class PlatformBridge {
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'convert_lyrics_to_romaji': convertLyricsToRomaji,
'track_number': trackNumber,
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
});
final result = await _channel.invokeMethod('downloadTrack', request);
@@ -88,13 +94,16 @@ class PlatformBridge {
String? coverUrl,
required String outputDir,
required String filenameFormat,
String quality = 'LOSSLESS',
bool embedLyrics = true,
bool embedMaxQualityCover = true,
bool convertLyricsToRomaji = false,
int trackNumber = 1,
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String preferredService = 'tidal',
String? itemId,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -107,24 +116,48 @@ class PlatformBridge {
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'convert_lyrics_to_romaji': convertLyricsToRomaji,
'track_number': trackNumber,
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
});
final result = await _channel.invokeMethod('downloadWithFallback', request);
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get download progress
/// Get download progress (legacy single download)
static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get progress for all active downloads (concurrent mode)
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
final result = await _channel.invokeMethod('getAllDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Initialize progress tracking for a download item
static Future<void> initItemProgress(String itemId) async {
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
}
/// Finish progress tracking for a download item
static Future<void> finishItemProgress(String itemId) async {
await _channel.invokeMethod('finishItemProgress', {'item_id': itemId});
}
/// Clear progress tracking for a download item
static Future<void> clearItemProgress(String itemId) async {
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
}
/// Set download directory
static Future<void> setDownloadDirectory(String path) async {
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
@@ -195,4 +228,10 @@ class PlatformBridge {
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Cleanup idle HTTP connections to prevent TCP exhaustion
/// Call this periodically during large batch downloads
static Future<void> cleanupConnections() async {
await _channel.invokeMethod('cleanupConnections');
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
/// 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) => print('[ShareIntent] 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) {
print('[ShareIntent] 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();
}
}
+159
View File
@@ -0,0 +1,159 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:spotiflac_android/constants/app_info.dart';
class UpdateInfo {
final String version;
final String changelog;
final String downloadUrl;
final String? apkDownloadUrl; // Direct APK download URL
final DateTime publishedAt;
const UpdateInfo({
required this.version,
required this.changelog,
required this.downloadUrl,
this.apkDownloadUrl,
required this.publishedAt,
});
}
class UpdateChecker {
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
/// Get device CPU architecture
static Future<String> _getDeviceArch() async {
if (!Platform.isAndroid) return 'unknown';
try {
// Read CPU info from /proc/cpuinfo
final cpuInfo = await File('/proc/cpuinfo').readAsString();
// Check for 64-bit indicators
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
return 'arm64';
}
// Check architecture from uname
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'; // Default to arm64 for modern devices
} catch (e) {
print('[UpdateChecker] Error detecting arch: $e');
return 'arm64'; // Default fallback
}
}
/// Check for updates from GitHub releases
static Future<UpdateInfo?> checkForUpdate() async {
try {
final response = await http.get(
Uri.parse(_apiUrl),
headers: {'Accept': 'application/vnd.github.v3+json'},
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
print('[UpdateChecker] GitHub API returned ${response.statusCode}');
return null;
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final tagName = data['tag_name'] as String? ?? '';
final latestVersion = tagName.replaceFirst('v', '');
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
print('[UpdateChecker] 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();
// Find APK download URL from assets based on device architecture
final deviceArch = await _getDeviceArch();
print('[UpdateChecker] 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;
}
}
}
// Select APK based on device architecture
String? apkUrl;
if (deviceArch == 'arm64') {
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
} else if (deviceArch == 'arm32') {
apkUrl = arm32Url ?? universalUrl;
} else {
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
}
print('[UpdateChecker] 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');
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();
// Pad with zeros if needed
while (latestParts.length < 3) {
latestParts.add(0);
}
while (currentParts.length < 3) {
currentParts.add(0);
}
for (int i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false; // Same version
} catch (e) {
return false;
}
}
static String get currentVersion => AppInfo.version;
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
/// A collapsing header widget
/// Title collapses from large to small when scrolling
class CollapsingHeader extends StatelessWidget {
final String title;
final bool showBackButton;
final Widget? infoCard;
final List<Widget> slivers;
const CollapsingHeader({
super.key,
required this.title,
this.showBackButton = false,
this.infoCard,
required this.slivers,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 140,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: showBackButton
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
)
: null,
automaticallyImplyLeading: false,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final expandRatio = _calculateExpandRatio(constraints, topPadding);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 24).evaluate(animation),
),
child: Text(
title,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},
),
),
// Info card if provided
if (infoCard != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: infoCard,
),
),
// Content slivers
...slivers,
],
);
}
double _calculateExpandRatio(BoxConstraints constraints, double topPadding) {
final maxHeight = 140;
final minHeight = kToolbarHeight + topPadding;
final currentHeight = constraints.maxHeight;
final expandRatio = (currentHeight - minHeight) / (maxHeight - minHeight);
return expandRatio.clamp(0.0, 1.0);
}
}
/// Section header for settings
class SettingsSection extends StatelessWidget {
final String title;
const SettingsSection({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
}
/// Info card widget (like version info)
class InfoCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback? onTap;
const InfoCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant)),
],
),
],
),
),
),
);
}
}
+314
View File
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/services/apk_downloader.dart';
import 'package:spotiflac_android/services/notification_service.dart';
class UpdateDialog extends StatefulWidget {
final UpdateInfo updateInfo;
final VoidCallback onDismiss;
final VoidCallback onDisableUpdates;
const UpdateDialog({
super.key,
required this.updateInfo,
required this.onDismiss,
required this.onDisableUpdates,
});
@override
State<UpdateDialog> createState() => _UpdateDialogState();
}
class _UpdateDialogState extends State<UpdateDialog> {
bool _isDownloading = false;
double _progress = 0;
String _statusText = '';
Future<void> _downloadAndInstall() async {
final apkUrl = widget.updateInfo.apkDownloadUrl;
// If no direct APK URL, open release page
if (apkUrl == null) {
final uri = Uri.parse(widget.updateInfo.downloadUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
if (mounted) Navigator.pop(context);
return;
}
setState(() {
_isDownloading = true;
_progress = 0;
_statusText = 'Starting download...';
});
final notificationService = NotificationService();
final filePath = await ApkDownloader.downloadApk(
url: apkUrl,
version: widget.updateInfo.version,
onProgress: (received, total) {
if (mounted) {
setState(() {
_progress = total > 0 ? received / total : 0;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
_statusText = '$receivedMB / $totalMB MB';
});
}
// Update notification
notificationService.showUpdateDownloadProgress(
version: widget.updateInfo.version,
received: received,
total: total,
);
},
);
if (filePath != null) {
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;
return AlertDialog(
title: Row(
children: [
Icon(Icons.system_update, color: colorScheme.primary),
const SizedBox(width: 12),
const Text('Update Available'),
],
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Version info
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Text(
'v${AppInfo.version}',
style: TextStyle(color: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 8),
Text(
'v${widget.updateInfo.version}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
// Changelog header
Text(
'What\'s New:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// 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: _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'),
),
],
);
}
/// Format changelog - clean up markdown and extract relevant content
String _formatChangelog(String changelog) {
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
var content = changelog;
// Find content after "What's New" header
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
if (whatsNewMatch != null) {
content = content.substring(whatsNewMatch.end);
}
// Cut off at "Downloads" section or horizontal rule
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
if (cutoffMatch != null) {
content = content.substring(0, cutoffMatch.start);
}
// Process line by line for better formatting
final lines = content.split('\n');
final formattedLines = <String>[];
String? currentSection;
for (var line in lines) {
line = line.trim();
if (line.isEmpty) continue;
// Check if it's a section header (### Added, ### Fixed, etc.)
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
if (sectionMatch != null) {
currentSection = sectionMatch.group(1)?.trim();
if (currentSection != null && currentSection.isNotEmpty) {
if (formattedLines.isNotEmpty) formattedLines.add('');
formattedLines.add('$currentSection:');
}
continue;
}
// Check if it's a list item
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
if (listMatch != null) {
var itemText = listMatch.group(1) ?? '';
// Remove bold markdown
itemText = itemText.replaceAllMapped(
RegExp(r'\*\*([^*]+)\*\*'),
(m) => m.group(1) ?? ''
);
// Remove code markdown
itemText = itemText.replaceAllMapped(
RegExp(r'`([^`]+)`'),
(m) => m.group(1) ?? ''
);
formattedLines.add('$itemText');
continue;
}
// Check if it's a sub-item (indented list)
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
if (subListMatch != null) {
var itemText = subListMatch.group(1) ?? '';
itemText = itemText.replaceAllMapped(
RegExp(r'\*\*([^*]+)\*\*'),
(m) => m.group(1) ?? ''
);
formattedLines.add(' - $itemText');
continue;
}
}
var formatted = formattedLines.join('\n').trim();
// Limit length
if (formatted.length > 2000) {
formatted = '${formatted.substring(0, 2000)}...';
}
return formatted.isEmpty ? 'See release notes for details.' : formatted;
}
}
/// Show update dialog
Future<void> showUpdateDialog(
BuildContext context, {
required UpdateInfo updateInfo,
required VoidCallback onDisableUpdates,
}) async {
return showDialog(
context: context,
builder: (context) => UpdateDialog(
updateInfo: updateInfo,
onDismiss: () {},
onDisableUpdates: onDisableUpdates,
),
);
}
+40
View File
@@ -382,6 +382,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.dev"
source: hosted
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -800,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:
@@ -1109,6 +1141,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.12"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
timing:
dependency: transitive
description:
+5 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 1.0.1+2
version: 1.5.5+22
environment:
sdk: ^3.10.0
@@ -47,10 +47,14 @@ dependencies:
url_launcher: ^6.3.1
device_info_plus: ^12.3.0
share_plus: ^10.1.4
receive_sharing_intent: ^1.8.1
# 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
dev_dependencies:
flutter_test:
@@ -75,4 +79,3 @@ flutter:
assets:
- assets/images/
- assets/icons/
+38
View File
@@ -0,0 +1,38 @@
# Changelog
## [1.1.0] - 2026-01-01
### Added
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
- Default: Sequential (1 at a time) for stability
- Options: 1, 2, or 3 concurrent downloads
- Warning about potential rate limiting from streaming services
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
### Fixed
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
### Changed
- Updated version to 1.1.0
### Technical Details
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
- Added shared `http.Transport` with connection pooling in `httputil.go`
- Added `CleanupConnections()` export for Flutter to call via method channel
## [1.0.5] - Previous Release
- Material Expressive 3 UI
- Dynamic color support
- Swipe navigation with PageView
- Settings as bottom navigation tab
- APK size optimization
-30
View File
@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}