mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f66916a58 | |||
| 8ac679003e | |||
| 6a1265eac3 | |||
| 9570547ff9 | |||
| ef62fb218a | |||
| ba5c91090c | |||
| c454bcd5ee | |||
| 4d2ee6fca6 | |||
| 89851bbd62 | |||
| 2c614f9e2f | |||
| f36bee1095 | |||
| e4218a1894 | |||
| db335f5ba6 | |||
| ab9869a849 | |||
| 34791310b7 | |||
| 97e366b5ef | |||
| 3a4019a55e | |||
| 320bc09b57 | |||
| c033c73a95 | |||
| d4d3a48167 | |||
| 52310dd801 | |||
| f374af2023 | |||
| b0016fb510 | |||
| b3f5c7b2dc |
@@ -2,9 +2,8 @@ name: Android Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Auto Release on Version Bump
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pubspec.yaml'
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version_changed: ${{ steps.check.outputs.changed }}
|
||||
new_version: ${{ steps.check.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check if version changed
|
||||
id: check
|
||||
run: |
|
||||
# Get current version
|
||||
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
|
||||
# Get previous version
|
||||
git show HEAD~1:pubspec.yaml > /tmp/old_pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > /tmp/old_pubspec.yaml
|
||||
PREVIOUS_VERSION=$(grep '^version:' /tmp/old_pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "Previous version: $PREVIOUS_VERSION"
|
||||
|
||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
||||
echo "Version changed!"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "version=v$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Version unchanged"
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
create-tag-and-trigger-release:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag ${{ needs.check-version.outputs.new_version }}
|
||||
git push origin ${{ needs.check-version.outputs.new_version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Release workflow
|
||||
run: |
|
||||
gh workflow run release.yml -f version=${{ needs.check-version.outputs.new_version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -2,9 +2,8 @@ name: iOS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -34,6 +33,52 @@ jobs:
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Verify XCFramework created
|
||||
run: |
|
||||
echo "=== Checking XCFramework ==="
|
||||
ls -la ios/Frameworks/
|
||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = 'ios/Runner.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
# Get the main target
|
||||
target = project.targets.find { |t| t.name == 'Runner' }
|
||||
|
||||
# Get or create Frameworks group
|
||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||
|
||||
# Add XCFramework reference
|
||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||
|
||||
# Add to frameworks build phase
|
||||
frameworks_build_phase = target.frameworks_build_phase
|
||||
frameworks_build_phase.add_file_reference(framework_ref)
|
||||
|
||||
# Add to embed frameworks build phase
|
||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||
if embed_phase
|
||||
build_file = embed_phase.add_file_reference(framework_ref)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||
end
|
||||
|
||||
project.save
|
||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||
EOF
|
||||
|
||||
ruby add_framework.rb
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
|
||||
+151
-41
@@ -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
|
||||
|
||||
@@ -71,12 +87,16 @@ jobs:
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release)
|
||||
run: flutter build apk --release
|
||||
run: flutter build apk --release --split-per-abi
|
||||
|
||||
- name: Rename APK
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
VERSION=${{ steps.get_version.outputs.version }}
|
||||
mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SpotiFLAC-${VERSION}-android.apk
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cd build/app/outputs/flutter-apk
|
||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||
ls -la
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -86,7 +106,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
|
||||
@@ -98,6 +118,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
|
||||
@@ -111,6 +139,51 @@ jobs:
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Verify XCFramework created
|
||||
run: |
|
||||
ls -la ios/Frameworks/
|
||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = 'ios/Runner.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
# Get the main target
|
||||
target = project.targets.find { |t| t.name == 'Runner' }
|
||||
|
||||
# Get or create Frameworks group
|
||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||
|
||||
# Add XCFramework reference
|
||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||
|
||||
# Add to frameworks build phase
|
||||
frameworks_build_phase = target.frameworks_build_phase
|
||||
frameworks_build_phase.add_file_reference(framework_ref)
|
||||
|
||||
# Add to embed frameworks build phase
|
||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||
if embed_phase
|
||||
build_file = embed_phase.add_file_reference(framework_ref)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||
end
|
||||
|
||||
project.save
|
||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||
EOF
|
||||
|
||||
ruby add_framework.rb
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
@@ -128,7 +201,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
|
||||
@@ -144,7 +217,7 @@ jobs:
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-android, build-ios]
|
||||
needs: [get-version, build-android, build-ios]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -152,6 +225,33 @@ 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
|
||||
|
||||
# Extract changelog section for this version
|
||||
# Look for ## [X.X.X] and capture until next ## [ or end of file
|
||||
CHANGELOG=$(awk -v ver="$VERSION_NUM" '
|
||||
/^## \[/ {
|
||||
if (found) exit
|
||||
if ($0 ~ "\\[" ver "\\]") found=1
|
||||
next
|
||||
}
|
||||
found { print }
|
||||
' CHANGELOG.md)
|
||||
|
||||
# If no changelog found, use default message
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
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:
|
||||
@@ -164,36 +264,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**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-android.apk`
|
||||
- **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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,9 +1,9 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Android/releases)
|
||||
|
||||

|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="icon.png" width="128" />
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||
@@ -11,26 +11,22 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Android/releases)
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Features
|
||||
## Screenshots
|
||||
|
||||
- 🔍 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
|
||||
<p align="center">
|
||||
<img src="assets/images/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
|
||||
<img src="assets/images/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
|
||||
<img src="assets/images/photo_2026-01-01_23-56-11.jpg" width="200" />
|
||||
<img src="assets/images/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
|
||||
<img src="assets/images/photo_2026-01-01_23-44-06.jpg" width="200" />
|
||||
</p>
|
||||
|
||||
## Screenshot
|
||||
|
||||
<!-- Add your screenshot here -->
|
||||
<!--  -->
|
||||
|
||||
## Related Project
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
The original desktop version for Windows, macOS, and Linux.
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -29,13 +29,34 @@ android {
|
||||
versionCode = flutter.versionCode
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
// Enable code shrinking and resource shrinking
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Split APKs by ABI for smaller individual downloads
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "armeabi-v7a")
|
||||
isUniversalApk = true // Also generate universal APK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
# Flutter specific rules
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
|
||||
# Go backend (gobackend.aar)
|
||||
-keep class gobackend.** { *; }
|
||||
-keep class go.** { *; }
|
||||
|
||||
# FFmpeg Kit
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-keep class com.arthenica.smartexception.** { *; }
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Kotlin coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# Prevent R8 from removing metadata
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
@@ -127,6 +127,12 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cleanupConnections" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cleanupConnections()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -5,6 +5,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -98,6 +99,7 @@ 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"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
@@ -124,6 +126,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 +181,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 +255,12 @@ func GetDownloadProgress() string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
+15
-1
@@ -346,8 +346,22 @@ 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)
|
||||
}
|
||||
|
||||
+28
-4
@@ -693,9 +693,19 @@ 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
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
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 +715,20 @@ 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)
|
||||
}
|
||||
|
||||
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 ProgressWriter for tracking
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -842,8 +859,15 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Go backend framework (Gobackend.xcframework)
|
||||
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
|
||||
OTHER_LDFLAGS=$(inherited) -framework Gobackend
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Go backend framework (Gobackend.xcframework)
|
||||
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
|
||||
OTHER_LDFLAGS=$(inherited) -framework Gobackend
|
||||
|
||||
@@ -93,7 +93,8 @@ import Gobackend // Import Go framework
|
||||
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":
|
||||
|
||||
+3
-2
@@ -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: '/',
|
||||
|
||||
@@ -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.2.0';
|
||||
static const String buildNumber = '10';
|
||||
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';
|
||||
}
|
||||
+18
-2
@@ -1,12 +1,28 @@
|
||||
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';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -22,6 +24,8 @@ class AppSettings {
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||
this.checkForUpdates = true, // Default: enabled
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -33,6 +37,8 @@ class AppSettings {
|
||||
bool? embedLyrics,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
bool? checkForUpdates,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -43,6 +49,8 @@ class AppSettings {
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,17 @@ 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,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
<String, dynamic>{
|
||||
@@ -27,4 +29,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
};
|
||||
|
||||
+39
-38
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new/return_code.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';
|
||||
|
||||
@@ -16,21 +19,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 +104,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +185,9 @@ class DownloadQueueState {
|
||||
final bool isProcessing;
|
||||
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 [],
|
||||
@@ -85,7 +195,9 @@ class DownloadQueueState {
|
||||
this.isProcessing = false,
|
||||
this.outputDir = '',
|
||||
this.filenameFormat = '{artist} - {title}',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.autoFallback = true,
|
||||
this.concurrentDownloads = 1,
|
||||
});
|
||||
|
||||
DownloadQueueState copyWith({
|
||||
@@ -94,7 +206,9 @@ class DownloadQueueState {
|
||||
bool? isProcessing,
|
||||
String? outputDir,
|
||||
String? filenameFormat,
|
||||
String? audioQuality,
|
||||
bool? autoFallback,
|
||||
int? concurrentDownloads,
|
||||
}) {
|
||||
return DownloadQueueState(
|
||||
items: items ?? this.items,
|
||||
@@ -102,18 +216,23 @@ class DownloadQueueState {
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
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
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
@@ -203,11 +322,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +352,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(
|
||||
@@ -371,7 +500,34 @@ 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;
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Queue processing finished');
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
}
|
||||
|
||||
/// Sequential download processing (original behavior)
|
||||
Future<void> _processQueueSequential() async {
|
||||
while (true) {
|
||||
final nextItem = state.items.firstWhere(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
@@ -388,130 +544,202 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
break;
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Processing: ${nextItem.track.name} by ${nextItem.track.artistName}');
|
||||
print('[DownloadQueue] Cover URL: ${nextItem.track.coverUrl}');
|
||||
await _downloadSingleItem(nextItem);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parallel download processing with worker pool
|
||||
Future<void> _processQueueParallel() async {
|
||||
final maxConcurrent = state.concurrentDownloads;
|
||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||
|
||||
while (true) {
|
||||
// Get queued items
|
||||
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
|
||||
|
||||
state = state.copyWith(currentDownload: nextItem);
|
||||
updateItemStatus(nextItem.id, DownloadStatus.downloading);
|
||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||
print('[DownloadQueue] No more items to process');
|
||||
break;
|
||||
}
|
||||
|
||||
// Start progress polling
|
||||
_startProgressPolling(nextItem.id);
|
||||
|
||||
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,
|
||||
);
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
|
||||
// Stop progress polling for this item
|
||||
_stopProgressPolling();
|
||||
// Start new downloads up to max concurrent limit
|
||||
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
Map<String, dynamic> result;
|
||||
|
||||
if (state.autoFallback) {
|
||||
print('[DownloadQueue] Using auto-fallback mode');
|
||||
print('[DownloadQueue] Quality: ${state.audioQuality}');
|
||||
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: state.outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
preferredService: item.service,
|
||||
);
|
||||
} 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: state.outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
} 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,18 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
||||
class HistoryScreen extends ConsumerWidget {
|
||||
const HistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final historyState = ref.watch(downloadHistoryProvider);
|
||||
final history = historyState.items;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Download History'),
|
||||
actions: [
|
||||
if (history.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
||||
tooltip: 'Clear history',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: history.isEmpty
|
||||
? _buildEmptyState(context, colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = history[index];
|
||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No download history',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Downloaded tracks will appear here',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
return Dismissible(
|
||||
key: Key(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
color: colorScheme.error,
|
||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
title: Text(
|
||||
item.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getServiceIcon(item.service),
|
||||
size: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDate(item.downloadedAt),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (!fileExists) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.warning,
|
||||
size: 12,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'File missing',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: fileExists
|
||||
? IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
onPressed: () => _openFile(context, item.filePath),
|
||||
)
|
||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getServiceIcon(String service) {
|
||||
switch (service.toLowerCase()) {
|
||||
case 'tidal':
|
||||
return Icons.waves;
|
||||
case 'qobuz':
|
||||
return Icons.album;
|
||||
case 'amazon':
|
||||
return Icons.shopping_cart;
|
||||
default:
|
||||
return Icons.cloud_download;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
if (diff.inHours == 0) {
|
||||
return '${diff.inMinutes}m ago';
|
||||
}
|
||||
return '${diff.inHours}h ago';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays}d ago';
|
||||
} else {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
final result = await OpenFilex.open(filePath);
|
||||
|
||||
if (result.type != ResultType.done) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cannot open: ${result.message}'),
|
||||
action: SnackBarAction(
|
||||
label: 'Copy Path',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: filePath));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Path copied to clipboard')),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (item.coverUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.trackName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.artistName,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
item.albumName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
if (File(item.filePath).existsSync())
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_openFile(context, item.filePath);
|
||||
},
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isPath ? 12 : 14,
|
||||
fontFamily: isPath ? 'monospace' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text(
|
||||
'Are you sure you want to clear all download history? '
|
||||
'This will not delete the downloaded files.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
||||
class HistoryTab extends ConsumerWidget {
|
||||
const HistoryTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final historyState = ref.watch(downloadHistoryProvider);
|
||||
final history = historyState.items;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with clear action
|
||||
if (history.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${history.length} downloads',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
||||
icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error),
|
||||
label: Text('Clear history', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// History list
|
||||
Expanded(
|
||||
child: history.isEmpty
|
||||
? _buildEmptyState(context, colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = history[index];
|
||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No download history',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Downloaded tracks will appear here',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
return Dismissible(
|
||||
key: Key(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
color: colorScheme.error,
|
||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
title: Text(
|
||||
item.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getServiceIcon(item.service),
|
||||
size: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDate(item.downloadedAt),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (!fileExists) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.warning,
|
||||
size: 12,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'File missing',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: fileExists
|
||||
? IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
onPressed: () => _openFile(context, item.filePath),
|
||||
)
|
||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getServiceIcon(String service) {
|
||||
switch (service.toLowerCase()) {
|
||||
case 'tidal':
|
||||
return Icons.waves;
|
||||
case 'qobuz':
|
||||
return Icons.album;
|
||||
case 'amazon':
|
||||
return Icons.shopping_cart;
|
||||
default:
|
||||
return Icons.cloud_download;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
if (diff.inHours == 0) {
|
||||
return '${diff.inMinutes}m ago';
|
||||
}
|
||||
return '${diff.inHours}h ago';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays}d ago';
|
||||
} else {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
final result = await OpenFilex.open(filePath);
|
||||
|
||||
if (result.type != ResultType.done) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cannot open: ${result.message}'),
|
||||
action: SnackBarAction(
|
||||
label: 'Copy Path',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: filePath));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Path copied to clipboard')),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (item.coverUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.trackName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.artistName,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
item.albumName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
if (File(item.filePath).existsSync())
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_openFile(context, item.filePath);
|
||||
},
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isPath ? 12 : 14,
|
||||
fontFamily: isPath ? 'monospace' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text(
|
||||
'Are you sure you want to clear all download history? '
|
||||
'This will not delete the downloaded files.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+85
-21
@@ -7,6 +7,7 @@ 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});
|
||||
@@ -326,25 +327,28 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
return ListTile(
|
||||
leading: item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
leading: Hero(
|
||||
tag: 'cover_${item.id}',
|
||||
child: item.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: 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,
|
||||
@@ -358,7 +362,26 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
onPressed: () => _openFile(item.filePath),
|
||||
)
|
||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||
onTap: fileExists ? () => _openFile(item.filePath) : null,
|
||||
// Tap to show metadata details
|
||||
onTap: () => _navigateToMetadataScreen(item),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,10 +466,51 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: historyState.items.length,
|
||||
itemBuilder: (context, index) => _buildHistoryTile(
|
||||
historyState.items[index],
|
||||
colorScheme,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = historyState.items[index];
|
||||
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: () {
|
||||
Navigator.pop(context); // Close bottom sheet first
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_navigateToMetadataScreen(item);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+49
-13
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings_tab.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,11 +18,43 @@ class MainShell extends ConsumerStatefulWidget {
|
||||
class _MainShellState extends ConsumerState<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
late PageController _pageController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
bool _isAnimating = false;
|
||||
|
||||
// Cache tab widgets to prevent rebuilds
|
||||
final List<Widget> _tabs = const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
// Check for updates after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdates();
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -29,16 +64,21 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
void _onNavTap(int index) {
|
||||
setState(() => _currentIndex = index);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
if (_currentIndex != index && !_isAnimating) {
|
||||
_isAnimating = true;
|
||||
setState(() => _currentIndex = index);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
).then((_) => _isAnimating = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
setState(() => _currentIndex = index);
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -63,12 +103,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
],
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: _tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
|
||||
@@ -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.0'),
|
||||
onTap: () => showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SpotiFLAC',
|
||||
applicationVersion: '1.0.0',
|
||||
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
@@ -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.0'),
|
||||
onTap: () => showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SpotiFLAC',
|
||||
applicationVersion: '1.0.0',
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,10 +314,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStepDot(0, 'Permission', colorScheme),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 2,
|
||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20), // Offset for label height
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 2,
|
||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
_buildStepDot(1, 'Folder', colorScheme),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
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';
|
||||
|
||||
/// Screen to display detailed metadata for a downloaded track
|
||||
/// Designed with Material Expressive 3 style
|
||||
class TrackMetadataScreen extends ConsumerWidget {
|
||||
final DownloadHistoryItem item;
|
||||
|
||||
const TrackMetadataScreen({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final fileExists = File(item.filePath).existsSync();
|
||||
|
||||
// Get file info
|
||||
int? fileSize;
|
||||
if (fileExists) {
|
||||
try {
|
||||
fileSize = File(item.filePath).lengthSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
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: 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 _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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
|
||||
/// FFmpeg service for audio conversion and remuxing
|
||||
class FFmpegService {
|
||||
|
||||
@@ -47,6 +47,7 @@ class PlatformBridge {
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
int trackNumber = 1,
|
||||
@@ -65,6 +66,7 @@ class PlatformBridge {
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
@@ -88,6 +90,7 @@ class PlatformBridge {
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
int trackNumber = 1,
|
||||
@@ -107,6 +110,7 @@ class PlatformBridge {
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
@@ -195,4 +199,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:convert';
|
||||
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 DateTime publishedAt;
|
||||
|
||||
const UpdateInfo({
|
||||
required this.version,
|
||||
required this.changelog,
|
||||
required this.downloadUrl,
|
||||
required this.publishedAt,
|
||||
});
|
||||
}
|
||||
|
||||
class UpdateChecker {
|
||||
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
|
||||
/// 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();
|
||||
|
||||
print('[UpdateChecker] Update available: $latestVersion');
|
||||
|
||||
return UpdateInfo(
|
||||
version: latestVersion,
|
||||
changelog: body,
|
||||
downloadUrl: htmlUrl,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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';
|
||||
|
||||
class UpdateDialog extends StatelessWidget {
|
||||
final UpdateInfo updateInfo;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onDisableUpdates;
|
||||
|
||||
const UpdateDialog({
|
||||
super.key,
|
||||
required this.updateInfo,
|
||||
required this.onDismiss,
|
||||
required this.onDisableUpdates,
|
||||
});
|
||||
|
||||
@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${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)
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
_formatChangelog(updateInfo.changelog),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Don't remind again button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDisableUpdates();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
'Don\'t remind',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
// Later button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Later'),
|
||||
),
|
||||
// Download button
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final uri = Uri.parse(updateInfo.downloadUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Format changelog - clean up markdown
|
||||
String _formatChangelog(String changelog) {
|
||||
// Remove markdown headers but keep content
|
||||
var formatted = changelog
|
||||
.replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '')
|
||||
.replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // Remove bold
|
||||
.replaceAll(RegExp(r'`([^`]+)`'), r'$1') // Remove code
|
||||
.trim();
|
||||
|
||||
// Limit length
|
||||
if (formatted.length > 1000) {
|
||||
formatted = '${formatted.substring(0, 1000)}...';
|
||||
}
|
||||
|
||||
return 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -313,14 +313,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
ffmpeg_kit_flutter_new:
|
||||
ffmpeg_kit_flutter_new_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ffmpeg_kit_flutter_new
|
||||
sha256: d127635f27e93a7f21f0a14ce0a1a148e80919c402dac4a2118d73bfb17ce841
|
||||
name: ffmpeg_kit_flutter_new_audio
|
||||
sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "2.0.0"
|
||||
ffmpeg_kit_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+3
-4
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
version: 1.2.0+10
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -48,8 +48,8 @@ dependencies:
|
||||
device_info_plus: ^12.3.0
|
||||
share_plus: ^10.1.4
|
||||
|
||||
# FFmpeg for audio conversion
|
||||
ffmpeg_kit_flutter_new: ^4.1.0
|
||||
# FFmpeg for audio conversion (audio-only version - much smaller)
|
||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||
open_filex: ^4.7.0
|
||||
|
||||
dev_dependencies:
|
||||
@@ -75,4 +75,3 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/icons/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user