mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f66916a58 | |||
| 8ac679003e | |||
| 6a1265eac3 | |||
| 9570547ff9 | |||
| ef62fb218a | |||
| ba5c91090c | |||
| c454bcd5ee | |||
| 4d2ee6fca6 | |||
| 89851bbd62 | |||
| 2c614f9e2f | |||
| f36bee1095 | |||
| e4218a1894 | |||
| db335f5ba6 | |||
| ab9869a849 | |||
| 34791310b7 |
@@ -38,10 +38,46 @@ jobs:
|
|||||||
echo "=== Checking XCFramework ==="
|
echo "=== Checking XCFramework ==="
|
||||||
ls -la ios/Frameworks/
|
ls -la ios/Frameworks/
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||||
echo "=== Debug.xcconfig ==="
|
|
||||||
cat ios/Flutter/Debug.xcconfig
|
- name: Add XCFramework to Xcode project
|
||||||
echo "=== Release.xcconfig ==="
|
run: |
|
||||||
cat ios/Flutter/Release.xcconfig
|
# 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
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
|
|||||||
+139
-46
@@ -12,17 +12,14 @@ on:
|
|||||||
default: 'v1.0.0'
|
default: 'v1.0.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android:
|
# Get version first (quick job)
|
||||||
|
get-version:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||||
@@ -30,6 +27,15 @@ jobs:
|
|||||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
fi
|
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
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
@@ -42,6 +48,16 @@ jobs:
|
|||||||
go-version: '1.21'
|
go-version: '1.21'
|
||||||
cache-dependency-path: go_backend/go.sum
|
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
|
- name: Install Android SDK & NDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
@@ -75,12 +91,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ steps.get_version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cd build/app/outputs/flutter-apk
|
cd build/app/outputs/flutter-apk
|
||||||
# Rename split APKs
|
|
||||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||||
# Also rename universal if exists
|
|
||||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
@@ -92,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: build-android
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -104,6 +118,14 @@ jobs:
|
|||||||
go-version: '1.21'
|
go-version: '1.21'
|
||||||
cache-dependency-path: go_backend/go.sum
|
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
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||||
@@ -119,13 +141,48 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify XCFramework created
|
- name: Verify XCFramework created
|
||||||
run: |
|
run: |
|
||||||
echo "=== Checking XCFramework ==="
|
|
||||||
ls -la ios/Frameworks/
|
ls -la ios/Frameworks/
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||||
echo "=== Debug.xcconfig ==="
|
|
||||||
cat ios/Flutter/Debug.xcconfig
|
- name: Add XCFramework to Xcode project
|
||||||
echo "=== Release.xcconfig ==="
|
run: |
|
||||||
cat ios/Flutter/Release.xcconfig
|
# 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
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
@@ -144,7 +201,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create IPA
|
- name: Create IPA
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.build-android.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
mkdir -p build/ios/ipa
|
mkdir -p build/ios/ipa
|
||||||
cd build/ios/iphoneos
|
cd build/ios/iphoneos
|
||||||
mkdir Payload
|
mkdir Payload
|
||||||
@@ -160,7 +217,7 @@ jobs:
|
|||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-android, build-ios]
|
needs: [get-version, build-android, build-ios]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -168,6 +225,33 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -180,37 +264,46 @@ jobs:
|
|||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
|
- name: Prepare release body
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
|
## SpotiFLAC $VERSION
|
||||||
|
|
||||||
|
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
||||||
|
|
||||||
|
### What's New
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
# Replace $VERSION in header
|
||||||
|
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
||||||
|
|
||||||
|
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||||
|
|
||||||
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
||||||
|
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||||
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
FOOTER
|
||||||
|
|
||||||
|
echo "Release body:"
|
||||||
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.build-android.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.build-android.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
body: |
|
body_path: /tmp/release_body.txt
|
||||||
## SpotiFLAC ${{ needs.build-android.outputs.version }}
|
files: ./release/*
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- **Android (arm64)**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-arm64.apk` (recommended for most devices)
|
|
||||||
- **Android (arm32)**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-arm32.apk` (for older devices)
|
|
||||||
- **iOS**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-ios-unsigned.ipa` (requires sideloading)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Search Spotify tracks, albums, and playlists
|
|
||||||
- Download in FLAC quality from multiple sources
|
|
||||||
- Automatic fallback to available services
|
|
||||||
- Embedded metadata and cover art
|
|
||||||
- Lyrics support (synced and plain)
|
|
||||||
- Material 3 Expressive UI with dynamic colors
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
|
||||||
|
|
||||||
---
|
|
||||||
*Note: iOS IPA is unsigned and requires sideloading*
|
|
||||||
files: |
|
|
||||||
./release/*
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
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-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="icon.png" width="128" />
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||
@@ -13,9 +13,15 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||
## Screenshot
|
## Screenshots
|
||||||
|
|
||||||
<!--  -->
|
<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>
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"cleanupConnections" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.cleanupConnections()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ type DownloadRequest struct {
|
|||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
FilenameFormat string `json:"filename_format"`
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
@@ -124,6 +126,13 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return errorResponse("Invalid request: " + err.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 filePath string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -172,6 +181,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
return errorResponse("Invalid request: " + err.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
|
// Build service order starting with preferred service
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
@@ -239,6 +255,12 @@ func GetDownloadProgress() string {
|
|||||||
return string(jsonBytes)
|
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
|
// SetDownloadDirectory sets the default download directory
|
||||||
func SetDownloadDirectory(path string) error {
|
func SetDownloadDirectory(path string) error {
|
||||||
return setDownloadDir(path)
|
return setDownloadDir(path)
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
return s
|
// Trim leading/trailing whitespace to prevent filename issues
|
||||||
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+48
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -41,13 +42,59 @@ const (
|
|||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
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
|
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||||
|
// Uses shared transport for connection reuse
|
||||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
return &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
|
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|||||||
+15
-1
@@ -346,8 +346,22 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
return "EXISTS:" + outputPath, nil
|
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
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", 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,
|
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 != "" {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
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)
|
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)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
_, err = io.Copy(out, resp.Body)
|
// Use ProgressWriter for tracking
|
||||||
|
progressWriter := NewProgressWriter(out)
|
||||||
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,8 +859,15 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
return "EXISTS:" + outputPath, nil
|
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
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS")
|
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ import Gobackend // Import Go framework
|
|||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
try GobackendSetDownloadDirectory(path)
|
GobackendSetDownloadDirectory(path, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "checkDuplicate":
|
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';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final settings = ref.watch(settingsProvider);
|
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||||
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
|
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -0,0 +1,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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(
|
runApp(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: SpotiFLACApp(),
|
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(
|
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
track: Track.fromJson(json['track'] as Map<String, dynamic>),
|
track: Track.fromJson(json['track'] as Map<String, dynamic>),
|
||||||
service: json['service'] as String,
|
service: json['service'] as String,
|
||||||
status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
status:
|
||||||
DownloadStatus.queued,
|
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
DownloadStatus.queued,
|
||||||
filePath: json['filePath'] as String?,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
error: json['error'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
error: json['error'] as String?,
|
||||||
);
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'track': instance.track.toJson(),
|
'track': instance.track,
|
||||||
'service': instance.service,
|
'service': instance.service,
|
||||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
@@ -37,22 +38,3 @@ const _$DownloadStatusEnumMap = {
|
|||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
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 embedLyrics;
|
||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
|
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||||
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -22,6 +24,8 @@ class AppSettings {
|
|||||||
this.embedLyrics = true,
|
this.embedLyrics = true,
|
||||||
this.maxQualityCover = true,
|
this.maxQualityCover = true,
|
||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
|
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||||
|
this.checkForUpdates = true, // Default: enabled
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -33,6 +37,8 @@ class AppSettings {
|
|||||||
bool? embedLyrics,
|
bool? embedLyrics,
|
||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
|
int? concurrentDownloads,
|
||||||
|
bool? checkForUpdates,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -43,6 +49,8 @@ class AppSettings {
|
|||||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
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(
|
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
defaultService: json['defaultService'] as String? ?? 'tidal',
|
defaultService: json['defaultService'] as String? ?? 'tidal',
|
||||||
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
||||||
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
||||||
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
||||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
);
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -27,4 +29,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'embedLyrics': instance.embedLyrics,
|
'embedLyrics': instance.embedLyrics,
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'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(
|
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
artistName: json['artistName'] as String,
|
artistName: json['artistName'] as String,
|
||||||
albumName: json['albumName'] as String,
|
albumName: json['albumName'] as String,
|
||||||
albumArtist: json['albumArtist'] as String?,
|
albumArtist: json['albumArtist'] as String?,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
isrc: json['isrc'] as String?,
|
isrc: json['isrc'] as String?,
|
||||||
duration: (json['duration'] as num).toInt(),
|
duration: (json['duration'] as num).toInt(),
|
||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
availability: json['availability'] == null
|
availability: json['availability'] == null
|
||||||
? null
|
? null
|
||||||
: ServiceAvailability.fromJson(
|
: ServiceAvailability.fromJson(
|
||||||
json['availability'] as Map<String, dynamic>),
|
json['availability'] as Map<String, dynamic>,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'artistName': instance.artistName,
|
'artistName': instance.artistName,
|
||||||
'albumName': instance.albumName,
|
'albumName': instance.albumName,
|
||||||
'albumArtist': instance.albumArtist,
|
'albumArtist': instance.albumArtist,
|
||||||
'coverUrl': instance.coverUrl,
|
'coverUrl': instance.coverUrl,
|
||||||
'isrc': instance.isrc,
|
'isrc': instance.isrc,
|
||||||
'duration': instance.duration,
|
'duration': instance.duration,
|
||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
'availability': instance.availability?.toJson(),
|
'availability': instance.availability,
|
||||||
};
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
ServiceAvailability(
|
ServiceAvailability(
|
||||||
@@ -50,12 +51,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
||||||
ServiceAvailability instance) =>
|
ServiceAvailability instance,
|
||||||
<String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'tidal': instance.tidal,
|
'tidal': instance.tidal,
|
||||||
'qobuz': instance.qobuz,
|
'qobuz': instance.qobuz,
|
||||||
'amazon': instance.amazon,
|
'amazon': instance.amazon,
|
||||||
'tidalUrl': instance.tidalUrl,
|
'tidalUrl': instance.tidalUrl,
|
||||||
'qobuzUrl': instance.qobuzUrl,
|
'qobuzUrl': instance.qobuzUrl,
|
||||||
'amazonUrl': instance.amazonUrl,
|
'amazonUrl': instance.amazonUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/models/track.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/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
|
|
||||||
@@ -16,21 +19,76 @@ class DownloadHistoryItem {
|
|||||||
final String trackName;
|
final String trackName;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
final String albumName;
|
final String albumName;
|
||||||
|
final String? albumArtist;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String filePath;
|
final String filePath;
|
||||||
final String service;
|
final String service;
|
||||||
final DateTime downloadedAt;
|
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({
|
const DownloadHistoryItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.trackName,
|
required this.trackName,
|
||||||
required this.artistName,
|
required this.artistName,
|
||||||
required this.albumName,
|
required this.albumName,
|
||||||
|
this.albumArtist,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.filePath,
|
required this.filePath,
|
||||||
required this.service,
|
required this.service,
|
||||||
required this.downloadedAt,
|
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
|
// Download History State
|
||||||
@@ -46,23 +104,73 @@ class DownloadHistoryState {
|
|||||||
|
|
||||||
// Download History Notifier (Riverpod 3.x)
|
// Download History Notifier (Riverpod 3.x)
|
||||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||||
|
static const _storageKey = 'download_history';
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadHistoryState build() {
|
DownloadHistoryState build() {
|
||||||
|
// Load history from storage on init
|
||||||
|
_loadFromStorageSync();
|
||||||
return const DownloadHistoryState();
|
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) {
|
void addToHistory(DownloadHistoryItem item) {
|
||||||
state = state.copyWith(items: [item, ...state.items]);
|
state = state.copyWith(items: [item, ...state.items]);
|
||||||
|
_saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeFromHistory(String id) {
|
void removeFromHistory(String id) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: state.items.where((item) => item.id != id).toList(),
|
items: state.items.where((item) => item.id != id).toList(),
|
||||||
);
|
);
|
||||||
|
_saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearHistory() {
|
void clearHistory() {
|
||||||
state = const DownloadHistoryState();
|
state = const DownloadHistoryState();
|
||||||
|
_saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +185,9 @@ class DownloadQueueState {
|
|||||||
final bool isProcessing;
|
final bool isProcessing;
|
||||||
final String outputDir;
|
final String outputDir;
|
||||||
final String filenameFormat;
|
final String filenameFormat;
|
||||||
|
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
final bool autoFallback;
|
final bool autoFallback;
|
||||||
|
final int concurrentDownloads; // 1 = sequential, max 3
|
||||||
|
|
||||||
const DownloadQueueState({
|
const DownloadQueueState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
@@ -85,7 +195,9 @@ class DownloadQueueState {
|
|||||||
this.isProcessing = false,
|
this.isProcessing = false,
|
||||||
this.outputDir = '',
|
this.outputDir = '',
|
||||||
this.filenameFormat = '{artist} - {title}',
|
this.filenameFormat = '{artist} - {title}',
|
||||||
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.autoFallback = true,
|
this.autoFallback = true,
|
||||||
|
this.concurrentDownloads = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadQueueState copyWith({
|
DownloadQueueState copyWith({
|
||||||
@@ -94,7 +206,9 @@ class DownloadQueueState {
|
|||||||
bool? isProcessing,
|
bool? isProcessing,
|
||||||
String? outputDir,
|
String? outputDir,
|
||||||
String? filenameFormat,
|
String? filenameFormat,
|
||||||
|
String? audioQuality,
|
||||||
bool? autoFallback,
|
bool? autoFallback,
|
||||||
|
int? concurrentDownloads,
|
||||||
}) {
|
}) {
|
||||||
return DownloadQueueState(
|
return DownloadQueueState(
|
||||||
items: items ?? this.items,
|
items: items ?? this.items,
|
||||||
@@ -102,18 +216,23 @@ class DownloadQueueState {
|
|||||||
isProcessing: isProcessing ?? this.isProcessing,
|
isProcessing: isProcessing ?? this.isProcessing,
|
||||||
outputDir: outputDir ?? this.outputDir,
|
outputDir: outputDir ?? this.outputDir,
|
||||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||||
|
audioQuality: audioQuality ?? this.audioQuality,
|
||||||
autoFallback: autoFallback ?? this.autoFallback,
|
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 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 completedCount => items.where((i) => i.status == DownloadStatus.completed).length;
|
||||||
int get failedCount => items.where((i) => i.status == DownloadStatus.failed).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)
|
// Download Queue Notifier (Riverpod 3.x)
|
||||||
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
|
int _downloadCount = 0; // Counter for connection cleanup
|
||||||
|
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadQueueState build() {
|
DownloadQueueState build() {
|
||||||
@@ -203,11 +322,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
||||||
filenameFormat: settings.filenameFormat,
|
filenameFormat: settings.filenameFormat,
|
||||||
|
audioQuality: settings.audioQuality,
|
||||||
autoFallback: settings.autoFallback,
|
autoFallback: settings.autoFallback,
|
||||||
|
concurrentDownloads: settings.concurrentDownloads,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String addToQueue(Track track, String service) {
|
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 id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final item = DownloadItem(
|
final item = DownloadItem(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -227,6 +352,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void addMultipleToQueue(List<Track> tracks, String service) {
|
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 newItems = tracks.map((track) {
|
||||||
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
@@ -371,7 +500,34 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print('[DownloadQueue] Output directory: ${state.outputDir}');
|
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) {
|
while (true) {
|
||||||
final nextItem = state.items.firstWhere(
|
final nextItem = state.items.firstWhere(
|
||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
@@ -388,130 +544,202 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[DownloadQueue] Processing: ${nextItem.track.name} by ${nextItem.track.artistName}');
|
await _downloadSingleItem(nextItem);
|
||||||
print('[DownloadQueue] Cover URL: ${nextItem.track.coverUrl}');
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||||
updateItemStatus(nextItem.id, DownloadStatus.downloading);
|
print('[DownloadQueue] No more items to process');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Start progress polling
|
// Start new downloads up to max concurrent limit
|
||||||
_startProgressPolling(nextItem.id);
|
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
|
||||||
|
final item = queuedItems.removeAt(0);
|
||||||
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();
|
|
||||||
|
|
||||||
print('[DownloadQueue] Result: $result');
|
// Mark as downloading immediately to prevent double-processing
|
||||||
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||||
|
|
||||||
if (result['success'] == true) {
|
// Create the download future
|
||||||
var filePath = result['file_path'] as String?;
|
final future = _downloadSingleItem(item).whenComplete(() {
|
||||||
print('[DownloadQueue] Download success, file: $filePath');
|
activeDownloads.remove(item.id);
|
||||||
|
});
|
||||||
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
activeDownloads[item.id] = future;
|
||||||
print('[DownloadQueue] Converting M4A to FLAC...');
|
print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
|
||||||
updateItemStatus(nextItem.id, DownloadStatus.downloading, progress: 0.9);
|
}
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
|
||||||
if (flacPath != null) {
|
// Wait for at least one download to complete before checking for more
|
||||||
filePath = flacPath;
|
if (activeDownloads.isNotEmpty) {
|
||||||
print('[DownloadQueue] Converted to: $flacPath');
|
await Future.any(activeDownloads.values);
|
||||||
|
|
||||||
// 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(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for all remaining downloads to complete
|
||||||
|
if (activeDownloads.isNotEmpty) {
|
||||||
|
await Future.wait(activeDownloads.values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_stopProgressPolling();
|
/// Download a single item (used by both sequential and parallel processing)
|
||||||
print('[DownloadQueue] Queue processing finished');
|
Future<void> _downloadSingleItem(DownloadItem item) async {
|
||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
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);
|
state = state.copyWith(isFirstLaunch: false);
|
||||||
_saveSettings();
|
_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>(
|
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/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
|
||||||
class HomeTab extends ConsumerStatefulWidget {
|
class HomeTab extends ConsumerStatefulWidget {
|
||||||
const HomeTab({super.key});
|
const HomeTab({super.key});
|
||||||
@@ -326,25 +327,28 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final fileExists = File(item.filePath).existsSync();
|
final fileExists = File(item.filePath).existsSync();
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: item.coverUrl != null
|
leading: Hero(
|
||||||
? ClipRRect(
|
tag: 'cover_${item.id}',
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: item.coverUrl != null
|
||||||
child: CachedNetworkImage(
|
? ClipRRect(
|
||||||
imageUrl: item.coverUrl!,
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 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),
|
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
item.artistName,
|
item.artistName,
|
||||||
@@ -358,7 +362,26 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
onPressed: () => _openFile(item.filePath),
|
onPressed: () => _openFile(item.filePath),
|
||||||
)
|
)
|
||||||
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
: 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(
|
child: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: historyState.items.length,
|
itemCount: historyState.items.length,
|
||||||
itemBuilder: (context, index) => _buildHistoryTile(
|
itemBuilder: (context, index) {
|
||||||
historyState.items[index],
|
final item = historyState.items[index];
|
||||||
colorScheme,
|
final fileExists = File(item.filePath).existsSync();
|
||||||
),
|
|
||||||
|
return ListTile(
|
||||||
|
leading: item.coverUrl != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.coverUrl!,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(
|
||||||
|
item.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
trailing: fileExists
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
|
onPressed: () => _openFile(item.filePath),
|
||||||
|
)
|
||||||
|
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
||||||
|
onTap: () {
|
||||||
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/settings_tab.dart';
|
import 'package:spotiflac_android/screens/settings_tab.dart';
|
||||||
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
|
|
||||||
class MainShell extends ConsumerStatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
@@ -15,11 +18,43 @@ class MainShell extends ConsumerStatefulWidget {
|
|||||||
class _MainShellState extends ConsumerState<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
|
bool _hasCheckedUpdate = false;
|
||||||
|
bool _isAnimating = false;
|
||||||
|
|
||||||
|
// Cache tab widgets to prevent rebuilds
|
||||||
|
final List<Widget> _tabs = const [
|
||||||
|
HomeTab(),
|
||||||
|
QueueTab(),
|
||||||
|
SettingsTab(),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
// Check for updates after first frame
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkForUpdates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkForUpdates() async {
|
||||||
|
if (_hasCheckedUpdate) return;
|
||||||
|
_hasCheckedUpdate = true;
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (!settings.checkForUpdates) return;
|
||||||
|
|
||||||
|
final updateInfo = await UpdateChecker.checkForUpdate();
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
showUpdateDialog(
|
||||||
|
context,
|
||||||
|
updateInfo: updateInfo,
|
||||||
|
onDisableUpdates: () {
|
||||||
|
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -29,16 +64,21 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onNavTap(int index) {
|
void _onNavTap(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index && !_isAnimating) {
|
||||||
_pageController.animateToPage(
|
_isAnimating = true;
|
||||||
index,
|
setState(() => _currentIndex = index);
|
||||||
duration: const Duration(milliseconds: 300),
|
_pageController.animateToPage(
|
||||||
curve: Curves.easeInOut,
|
index,
|
||||||
);
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
).then((_) => _isAnimating = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index) {
|
||||||
|
setState(() => _currentIndex = index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -63,12 +103,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
body: PageView(
|
body: PageView(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
children: const [
|
children: _tabs,
|
||||||
HomeTab(),
|
|
||||||
QueueTab(),
|
|
||||||
SettingsTab(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
|
||||||
@@ -54,9 +56,6 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Theme Preview
|
|
||||||
_buildThemePreview(context, colorScheme),
|
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// Download Section
|
// Download Section
|
||||||
@@ -125,6 +124,54 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
value: settings.maxQualityCover,
|
value: settings.maxQualityCover,
|
||||||
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
|
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(),
|
const Divider(),
|
||||||
|
|
||||||
@@ -132,19 +179,64 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
subtitle: const Text('SpotiFLAC v1.0.2'),
|
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||||
onTap: () => showAboutDialog(
|
onTap: () => _showAboutDialog(context),
|
||||||
context: context,
|
|
||||||
applicationName: 'SpotiFLAC',
|
|
||||||
applicationVersion: '1.0.2',
|
|
||||||
applicationLegalese: '© 2024 SpotiFLAC',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
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) {
|
String _getThemeModeName(ThemeMode mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case ThemeMode.light: return 'Light';
|
case ThemeMode.light: return 'Light';
|
||||||
@@ -423,4 +470,59 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
|
||||||
@@ -61,9 +63,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Theme Preview
|
|
||||||
_buildThemePreview(context, colorScheme),
|
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// Download Section
|
// Download Section
|
||||||
@@ -132,6 +131,54 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
value: settings.maxQualityCover,
|
value: settings.maxQualityCover,
|
||||||
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
|
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(),
|
const Divider(),
|
||||||
|
|
||||||
@@ -139,13 +186,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
subtitle: const Text('SpotiFLAC v1.0.2'),
|
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
|
||||||
onTap: () => showAboutDialog(
|
onTap: () => _showAboutDialog(context),
|
||||||
context: context,
|
|
||||||
applicationName: 'SpotiFLAC',
|
|
||||||
applicationVersion: '1.0.2',
|
|
||||||
applicationLegalese: '© 2024 SpotiFLAC',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding for navigation bar
|
// 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) {
|
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
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) {
|
String _getThemeModeName(ThemeMode mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case ThemeMode.light: return 'Light';
|
case ThemeMode.light: return 'Light';
|
||||||
@@ -222,8 +278,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
|
|
||||||
String _getQualityName(String quality) {
|
String _getQualityName(String quality) {
|
||||||
switch (quality) {
|
switch (quality) {
|
||||||
case 'LOSSLESS': return 'FLAC (Lossless)';
|
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
|
||||||
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
|
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
|
||||||
|
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
|
||||||
default: return quality;
|
default: return quality;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +391,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
|
||||||
|
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -392,4 +450,59 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
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,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildStepDot(0, 'Permission', colorScheme),
|
_buildStepDot(0, 'Permission', colorScheme),
|
||||||
Container(
|
Padding(
|
||||||
width: 40,
|
padding: const EdgeInsets.only(bottom: 20), // Offset for label height
|
||||||
height: 2,
|
child: Container(
|
||||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
width: 40,
|
||||||
|
height: 2,
|
||||||
|
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildStepDot(1, 'Folder', colorScheme),
|
_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);
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ class PlatformBridge {
|
|||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
required String outputDir,
|
required String outputDir,
|
||||||
required String filenameFormat,
|
required String filenameFormat,
|
||||||
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
@@ -65,6 +66,7 @@ class PlatformBridge {
|
|||||||
'cover_url': coverUrl,
|
'cover_url': coverUrl,
|
||||||
'output_dir': outputDir,
|
'output_dir': outputDir,
|
||||||
'filename_format': filenameFormat,
|
'filename_format': filenameFormat,
|
||||||
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
@@ -88,6 +90,7 @@ class PlatformBridge {
|
|||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
required String outputDir,
|
required String outputDir,
|
||||||
required String filenameFormat,
|
required String filenameFormat,
|
||||||
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
@@ -107,6 +110,7 @@ class PlatformBridge {
|
|||||||
'cover_url': coverUrl,
|
'cover_url': coverUrl,
|
||||||
'output_dir': outputDir,
|
'output_dir': outputDir,
|
||||||
'filename_format': filenameFormat,
|
'filename_format': filenameFormat,
|
||||||
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
@@ -195,4 +199,10 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-2
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.2+3
|
version: 1.2.0+10
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -75,4 +75,3 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- 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