Compare commits

...

24 Commits

Author SHA1 Message Date
zarzet 8f66916a58 v1.2.0: Track Metadata Screen, Hi-Res fix, Settings navigation fix 2026-01-02 00:03:05 +07:00
zarzet 8ac679003e v1.1.1: UI fixes, MIT license, history persistence improvements 2026-01-01 22:29:40 +07:00
zarzet 6a1265eac3 v1.1.0: Parallel downloads, bug fixes, history persistence 2026-01-01 22:09:39 +07:00
zarzet 9570547ff9 fix: trim whitespace from metadata fields to prevent filename issues 2026-01-01 21:45:33 +07:00
zarzet ef62fb218a fix: add connection pooling and periodic cleanup to prevent TCP exhaustion 2026-01-01 21:42:25 +07:00
zarzet ba5c91090c fix: add progress tracking for BTS downloads and persist history 2026-01-01 21:28:00 +07:00
zarzet c454bcd5ee docs: center logo in README 2026-01-01 21:14:17 +07:00
zarzet 4d2ee6fca6 docs: resize logo to 128px 2026-01-01 21:13:12 +07:00
zarzet 89851bbd62 docs: add screenshots to README 2026-01-01 21:11:22 +07:00
zarzet 2c614f9e2f chore: bump version to 1.0.5 2026-01-01 20:54:36 +07:00
zarzet f36bee1095 feat: add GitHub links and credits to Settings 2026-01-01 20:49:12 +07:00
zarzet e4218a1894 fix: correct GobackendSetDownloadDirectory call signature for iOS 2026-01-01 20:46:08 +07:00
zarzet db335f5ba6 chore: bump version to 1.0.3 2026-01-01 20:36:02 +07:00
zarzet ab9869a849 fix: add XCFramework to Xcode project dynamically for iOS build 2026-01-01 20:35:10 +07:00
zarzet 34791310b7 fix: remove empty assets/icons folder reference 2026-01-01 20:25:53 +07:00
zarzet 97e366b5ef chore: bump version to 1.0.2 2026-01-01 20:13:47 +07:00
zarzet 3a4019a55e fix: trigger release workflow directly from auto-release 2026-01-01 20:12:53 +07:00
zarzet 320bc09b57 chore: bump version to 1.0.1 2026-01-01 19:57:23 +07:00
zarzet c033c73a95 perf: reduce APK size - switch to audio-only FFmpeg, enable ProGuard, split APK by ABI 2026-01-01 19:54:45 +07:00
zarzet d4d3a48167 fix: add Gobackend framework search paths for iOS build 2026-01-01 19:50:54 +07:00
zarzet 52310dd801 Remove ignored files from tracking 2026-01-01 19:40:43 +07:00
zarzet f374af2023 Add auto-release on version bump, update repo URLs to SpotiFLAC-Mobile 2026-01-01 19:40:14 +07:00
zarzet b0016fb510 Build only on version tag push 2026-01-01 19:39:01 +07:00
zarzet b3f5c7b2dc Clean up README 2026-01-01 19:35:34 +07:00
47 changed files with 2521 additions and 1217 deletions
+2 -3
View File
@@ -2,9 +2,8 @@ name: Android Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
jobs:
+69
View File
@@ -0,0 +1,69 @@
name: Auto Release on Version Bump
on:
push:
branches: [main]
paths:
- 'pubspec.yaml'
jobs:
check-version:
runs-on: ubuntu-latest
outputs:
version_changed: ${{ steps.check.outputs.changed }}
new_version: ${{ steps.check.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if version changed
id: check
run: |
# Get current version
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
# Get previous version
git show HEAD~1:pubspec.yaml > /tmp/old_pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > /tmp/old_pubspec.yaml
PREVIOUS_VERSION=$(grep '^version:' /tmp/old_pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
echo "Current version: $CURRENT_VERSION"
echo "Previous version: $PREVIOUS_VERSION"
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
echo "Version changed!"
echo "changed=true" >> $GITHUB_OUTPUT
echo "version=v$CURRENT_VERSION" >> $GITHUB_OUTPUT
else
echo "Version unchanged"
echo "changed=false" >> $GITHUB_OUTPUT
fi
create-tag-and-trigger-release:
needs: check-version
if: needs.check-version.outputs.version_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create and push tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag ${{ needs.check-version.outputs.new_version }}
git push origin ${{ needs.check-version.outputs.new_version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Release workflow
run: |
gh workflow run release.yml -f version=${{ needs.check-version.outputs.new_version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+48 -3
View File
@@ -2,9 +2,8 @@ name: iOS Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
jobs:
@@ -34,6 +33,52 @@ jobs:
env:
CGO_ENABLED: 1
- name: Verify XCFramework created
run: |
echo "=== Checking XCFramework ==="
ls -la ios/Frameworks/
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
- name: Add XCFramework to Xcode project
run: |
# Install xcodeproj gem for modifying Xcode project
sudo gem install xcodeproj
# Create Ruby script to add framework
cat > add_framework.rb << 'EOF'
require 'xcodeproj'
project_path = 'ios/Runner.xcodeproj'
project = Xcodeproj::Project.open(project_path)
# Get the main target
target = project.targets.find { |t| t.name == 'Runner' }
# Get or create Frameworks group
frameworks_group = project.main_group.find_subpath('Frameworks', true)
frameworks_group ||= project.main_group.new_group('Frameworks')
# Add XCFramework reference
framework_path = 'Frameworks/Gobackend.xcframework'
framework_ref = frameworks_group.new_file(framework_path, :project)
# Add to frameworks build phase
frameworks_build_phase = target.frameworks_build_phase
frameworks_build_phase.add_file_reference(framework_ref)
# Add to embed frameworks build phase
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
if embed_phase
build_file = embed_phase.add_file_reference(framework_ref)
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
end
project.save
puts "Successfully added Gobackend.xcframework to Xcode project"
EOF
ruby add_framework.rb
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
+151 -41
View File
@@ -12,17 +12,14 @@ on:
default: 'v1.0.0'
jobs:
build-android:
# Get version first (quick job)
get-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get version
id: get_version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
@@ -30,6 +27,15 @@ jobs:
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
# Android and iOS build in PARALLEL
build-android:
runs-on: ubuntu-latest
needs: get-version
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
@@ -42,6 +48,16 @@ jobs:
go-version: '1.21'
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK
uses: android-actions/setup-android@v3
@@ -71,12 +87,16 @@ jobs:
run: dart run flutter_launcher_icons
- name: Build APK (Release)
run: flutter build apk --release
run: flutter build apk --release --split-per-abi
- name: Rename APK
- name: Rename APKs
run: |
VERSION=${{ steps.get_version.outputs.version }}
mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SpotiFLAC-${VERSION}-android.apk
VERSION=${{ needs.get-version.outputs.version }}
cd build/app/outputs/flutter-apk
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
ls -la
- name: Upload APK artifact
uses: actions/upload-artifact@v4
@@ -86,7 +106,7 @@ jobs:
build-ios:
runs-on: macos-latest
needs: build-android
needs: get-version # Only depends on version, NOT android build!
steps:
- name: Checkout repository
@@ -98,6 +118,14 @@ jobs:
go-version: '1.21'
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: pods-${{ runner.os }}-
- name: Install gomobile
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
@@ -111,6 +139,51 @@ jobs:
env:
CGO_ENABLED: 1
- name: Verify XCFramework created
run: |
ls -la ios/Frameworks/
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
- name: Add XCFramework to Xcode project
run: |
# Install xcodeproj gem for modifying Xcode project
sudo gem install xcodeproj
# Create Ruby script to add framework
cat > add_framework.rb << 'EOF'
require 'xcodeproj'
project_path = 'ios/Runner.xcodeproj'
project = Xcodeproj::Project.open(project_path)
# Get the main target
target = project.targets.find { |t| t.name == 'Runner' }
# Get or create Frameworks group
frameworks_group = project.main_group.find_subpath('Frameworks', true)
frameworks_group ||= project.main_group.new_group('Frameworks')
# Add XCFramework reference
framework_path = 'Frameworks/Gobackend.xcframework'
framework_ref = frameworks_group.new_file(framework_path, :project)
# Add to frameworks build phase
frameworks_build_phase = target.frameworks_build_phase
frameworks_build_phase.add_file_reference(framework_ref)
# Add to embed frameworks build phase
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
if embed_phase
build_file = embed_phase.add_file_reference(framework_ref)
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
end
project.save
puts "Successfully added Gobackend.xcframework to Xcode project"
EOF
ruby add_framework.rb
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -128,7 +201,7 @@ jobs:
- name: Create IPA
run: |
VERSION=${{ needs.build-android.outputs.version }}
VERSION=${{ needs.get-version.outputs.version }}
mkdir -p build/ios/ipa
cd build/ios/iphoneos
mkdir Payload
@@ -144,7 +217,7 @@ jobs:
create-release:
runs-on: ubuntu-latest
needs: [build-android, build-ios]
needs: [get-version, build-android, build-ios]
permissions:
contents: write
@@ -152,6 +225,33 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract changelog for version
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
# Extract changelog section for this version
# Look for ## [X.X.X] and capture until next ## [ or end of file
CHANGELOG=$(awk -v ver="$VERSION_NUM" '
/^## \[/ {
if (found) exit
if ($0 ~ "\\[" ver "\\]") found=1
next
}
found { print }
' CHANGELOG.md)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
CHANGELOG="See CHANGELOG.md for details."
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
uses: actions/download-artifact@v4
with:
@@ -164,36 +264,46 @@ jobs:
name: ios-ipa
path: ./release
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
## SpotiFLAC $VERSION
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
### What's New
HEADER
# Replace $VERSION in header
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
cat /tmp/changelog.txt >> /tmp/release_body.txt
cat >> /tmp/release_body.txt << FOOTER
---
### Downloads
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
FOOTER
echo "Release body:"
cat /tmp/release_body.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.build-android.outputs.version }}
name: SpotiFLAC ${{ needs.build-android.outputs.version }}
body: |
## SpotiFLAC ${{ needs.build-android.outputs.version }}
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
### Downloads
- **Android**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-android.apk`
- **iOS**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-ios-unsigned.ipa` (requires sideloading)
### Features
- Search Spotify tracks, albums, and playlists
- Download in FLAC quality from multiple sources
- Automatic fallback to available services
- Embedded metadata and cover art
- Lyrics support (synced and plain)
- Material 3 Expressive UI with dynamic colors
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
---
*Note: iOS IPA is unsigned and requires sideloading*
files: |
./release/*
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
prerelease: false
env:
+38
View File
@@ -0,0 +1,38 @@
# Changelog
## [1.1.0] - 2026-01-01
### Added
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
- Default: Sequential (1 at a time) for stability
- Options: 1, 2, or 3 concurrent downloads
- Warning about potential rate limiting from streaming services
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
### Fixed
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
### Changed
- Updated version to 1.1.0
### Technical Details
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
- Added shared `http.Transport` with connection pooling in `httputil.go`
- Added `CleanupConnections()` export for Flutter to call via method channel
## [1.0.5] - Previous Release
- Material Expressive 3 UI
- Dynamic color support
- Swipe navigation with PageView
- Settings as bottom navigation tab
- APK size optimization
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 zarzet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -18
View File
@@ -1,9 +1,9 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Android/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Android/releases)
![SpotiFLAC](icon.png)
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
<div align="center">
<img src="icon.png" width="128" />
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
@@ -11,26 +11,22 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
</div>
### [Download](https://github.com/zarzet/SpotiFLAC-Android/releases)
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Features
## Screenshots
- 🔍 Search Spotify tracks, albums, and playlists
- 📥 Download in FLAC quality from multiple sources
- 🔄 Automatic fallback to available services
- 🎵 Embedded metadata and cover art
- 📝 Lyrics support (synced and plain)
- 🎨 Material 3 Expressive UI with dynamic colors
<p align="center">
<img src="assets/images/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
<img src="assets/images/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
<img src="assets/images/photo_2026-01-01_23-56-11.jpg" width="200" />
<img src="assets/images/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
<img src="assets/images/photo_2026-01-01_23-44-06.jpg" width="200" />
</p>
## Screenshot
<!-- Add your screenshot here -->
<!-- ![Screenshot](screenshot.png) -->
## Related Project
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
The original desktop version for Windows, macOS, and Linux.
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## Disclaimer
+23 -2
View File
@@ -29,13 +29,34 @@ android {
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
// Only include arm64-v8a for smaller APK (most modern devices)
// Remove this line if you need to support older 32-bit devices
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = false
isShrinkResources = false
// Enable code shrinking and resource shrinking
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
// Split APKs by ABI for smaller individual downloads
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "armeabi-v7a")
isUniversalApk = true // Also generate universal APK
}
}
}
+33
View File
@@ -0,0 +1,33 @@
# Flutter specific rules
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Go backend (gobackend.aar)
-keep class gobackend.** { *; }
-keep class go.** { *; }
# FFmpeg Kit
-keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.smartexception.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Kotlin coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
# Prevent R8 from removing metadata
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
@@ -127,6 +127,12 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"cleanupConnections" -> {
withContext(Dispatchers.IO) {
Gobackend.cleanupConnections()
}
result.success(null)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+22
View File
@@ -5,6 +5,7 @@ package gobackend
import (
"context"
"encoding/json"
"strings"
"time"
)
@@ -98,6 +99,7 @@ type DownloadRequest struct {
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"`
@@ -124,6 +126,13 @@ func DownloadTrack(requestJSON string) (string, error) {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
var filePath string
var err error
@@ -172,6 +181,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
@@ -239,6 +255,12 @@ func GetDownloadProgress() string {
return string(jsonBytes)
}
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() {
CloseIdleConnections()
}
// SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
+2 -1
View File
@@ -63,7 +63,8 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s)
}
}
return ""
+48 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"math/rand"
"net"
"net/http"
"strconv"
"time"
@@ -41,13 +42,59 @@ const (
DefaultRetryDelay = 1 * time.Second // Initial retry delay
)
// Shared transport with connection pooling to prevent TCP exhaustion
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: true,
}
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{
Transport: sharedTransport,
Timeout: DefaultTimeout,
}
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{
Transport: sharedTransport,
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: sharedTransport,
Timeout: timeout,
}
}
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client {
return sharedClient
}
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
+15 -1
View File
@@ -346,8 +346,22 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
return "EXISTS:" + outputPath, nil
}
// Map quality from Tidal format to Qobuz format
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
qobuzQuality := "27" // Default to highest quality
switch req.Quality {
case "LOSSLESS":
qobuzQuality = "6" // 16-bit FLAC
case "HI_RES":
qobuzQuality = "7" // 24-bit 96kHz
case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz
}
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
+28 -4
View File
@@ -693,9 +693,19 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
Timeout: 120 * time.Second,
}
// If we have a direct URL (BTS format), download directly
// If we have a direct URL (BTS format), download directly with progress tracking
if directURL != "" {
resp, err := client.Get(directURL)
// Set current file being downloaded
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
req, err := http.NewRequest("GET", directURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -705,13 +715,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// Set total bytes for progress tracking
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
}
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
// Use ProgressWriter for tracking
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
return err
}
@@ -842,8 +859,15 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
return "EXISTS:" + outputPath, nil
}
// Determine quality to use (default to LOSSLESS if not specified)
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
fmt.Printf("[Tidal] Using quality: %s\n", quality)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS")
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
+4
View File
@@ -1 +1,5 @@
#include "Generated.xcconfig"
// Go backend framework (Gobackend.xcframework)
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
OTHER_LDFLAGS=$(inherited) -framework Gobackend
+4
View File
@@ -1 +1,5 @@
#include "Generated.xcconfig"
// Go backend framework (Gobackend.xcframework)
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
OTHER_LDFLAGS=$(inherited) -framework Gobackend
+2 -1
View File
@@ -93,7 +93,8 @@ import Gobackend // Import Go framework
case "setDownloadDirectory":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
try GobackendSetDownloadDirectory(path)
GobackendSetDownloadDirectory(path, &error)
if let error = error { throw error }
return nil
case "checkDuplicate":
+3 -2
View File
@@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
final _routerProvider = Provider<GoRouter>((ref) {
final settings = ref.watch(settingsProvider);
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
return GoRouter(
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
initialLocation: isFirstLaunch ? '/setup' : '/',
routes: [
GoRoute(
path: '/',
+17
View File
@@ -0,0 +1,17 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '1.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
View File
@@ -1,12 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(
const ProviderScope(
child: SpotiFLACApp(),
ProviderScope(
child: const _EagerInitialization(
child: SpotiFLACApp(),
),
),
);
}
/// Widget to eagerly initialize providers that need to load data on startup
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Eagerly initialize download history provider to load from storage
ref.watch(downloadHistoryProvider);
return child;
}
}
+12 -30
View File
@@ -7,21 +7,22 @@ part of 'download_item.dart';
// **************************************************************************
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
id: json['id'] as String,
track: Track.fromJson(json['track'] as Map<String, dynamic>),
service: json['service'] as String,
status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
id: json['id'] as String,
track: Track.fromJson(json['track'] as Map<String, dynamic>),
service: json['service'] as String,
status:
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
<String, dynamic>{
'id': instance.id,
'track': instance.track.toJson(),
'track': instance.track,
'service': instance.service,
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
@@ -37,22 +38,3 @@ const _$DownloadStatusEnumMap = {
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
};
K? $enumDecodeNullable<K, V>(
Map<K, V> enumValues,
Object? source, {
K? unknownValue,
}) {
if (source == null) {
return null;
}
return enumValues.entries
.singleWhere(
(e) => e.value == source,
orElse: () => throw ArgumentError(
'`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}',
),
)
.key;
}
+8
View File
@@ -12,6 +12,8 @@ class AppSettings {
final bool embedLyrics;
final bool maxQualityCover;
final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3
final bool checkForUpdates; // Check for updates on app start
const AppSettings({
this.defaultService = 'tidal',
@@ -22,6 +24,8 @@ class AppSettings {
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off)
this.checkForUpdates = true, // Default: enabled
});
AppSettings copyWith({
@@ -33,6 +37,8 @@ class AppSettings {
bool? embedLyrics,
bool? maxQualityCover,
bool? isFirstLaunch,
int? concurrentDownloads,
bool? checkForUpdates,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -43,6 +49,8 @@ class AppSettings {
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
);
}
+13 -9
View File
@@ -7,15 +7,17 @@ part of 'settings.dart';
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
defaultService: json['defaultService'] as String? ?? 'tidal',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
);
defaultService: json['defaultService'] as String? ?? 'tidal',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
<String, dynamic>{
@@ -27,4 +29,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
};
+39 -38
View File
@@ -7,37 +7,38 @@ part of 'track.dart';
// **************************************************************************
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
id: json['id'] as String,
name: json['name'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>),
);
id: json['id'] as String,
name: json['name'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'availability': instance.availability?.toJson(),
};
'id': instance.id,
'name': instance.name,
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'availability': instance.availability,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
ServiceAvailability(
@@ -50,12 +51,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
);
Map<String, dynamic> _$ServiceAvailabilityToJson(
ServiceAvailability instance) =>
<String, dynamic>{
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
};
ServiceAvailability instance,
) => <String, dynamic>{
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
};
+347 -119
View File
@@ -1,12 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
@@ -16,21 +19,76 @@ class DownloadHistoryItem {
final String trackName;
final String artistName;
final String albumName;
final String? albumArtist;
final String? coverUrl;
final String filePath;
final String service;
final DateTime downloadedAt;
// Additional metadata
final String? isrc;
final String? spotifyId;
final int? trackNumber;
final int? discNumber;
final int? duration;
final String? releaseDate;
final String? quality;
const DownloadHistoryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist,
this.coverUrl,
required this.filePath,
required this.service,
required this.downloadedAt,
this.isrc,
this.spotifyId,
this.trackNumber,
this.discNumber,
this.duration,
this.releaseDate,
this.quality,
});
Map<String, dynamic> toJson() => {
'id': id,
'trackName': trackName,
'artistName': artistName,
'albumName': albumName,
'albumArtist': albumArtist,
'coverUrl': coverUrl,
'filePath': filePath,
'service': service,
'downloadedAt': downloadedAt.toIso8601String(),
'isrc': isrc,
'spotifyId': spotifyId,
'trackNumber': trackNumber,
'discNumber': discNumber,
'duration': duration,
'releaseDate': releaseDate,
'quality': quality,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
id: json['id'] as String,
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
filePath: json['filePath'] as String,
service: json['service'] as String,
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
isrc: json['isrc'] as String?,
spotifyId: json['spotifyId'] as String?,
trackNumber: json['trackNumber'] as int?,
discNumber: json['discNumber'] as int?,
duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?,
quality: json['quality'] as String?,
);
}
// Download History State
@@ -46,23 +104,73 @@ class DownloadHistoryState {
// Download History Notifier (Riverpod 3.x)
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const _storageKey = 'download_history';
bool _isLoaded = false;
@override
DownloadHistoryState build() {
// Load history from storage on init
_loadFromStorageSync();
return const DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromStorageSync() {
if (_isLoaded) return;
Future.microtask(() async {
await _loadFromStorage();
_isLoaded = true;
});
}
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_storageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
state = state.copyWith(items: items);
print('[DownloadHistory] Loaded ${items.length} items from storage');
} else {
print('[DownloadHistory] No history found in storage');
}
} catch (e) {
print('[DownloadHistory] Failed to load history: $e');
}
}
Future<void> _saveToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonList = state.items.map((e) => e.toJson()).toList();
await prefs.setString(_storageKey, jsonEncode(jsonList));
print('[DownloadHistory] Saved ${state.items.length} items to storage');
} catch (e) {
print('[DownloadHistory] Failed to save history: $e');
}
}
/// Force reload from storage (useful after app restart)
Future<void> reloadFromStorage() async {
await _loadFromStorage();
}
void addToHistory(DownloadHistoryItem item) {
state = state.copyWith(items: [item, ...state.items]);
_saveToStorage();
}
void removeFromHistory(String id) {
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
);
_saveToStorage();
}
void clearHistory() {
state = const DownloadHistoryState();
_saveToStorage();
}
}
@@ -77,7 +185,9 @@ class DownloadQueueState {
final bool isProcessing;
final String outputDir;
final String filenameFormat;
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
final bool autoFallback;
final int concurrentDownloads; // 1 = sequential, max 3
const DownloadQueueState({
this.items = const [],
@@ -85,7 +195,9 @@ class DownloadQueueState {
this.isProcessing = false,
this.outputDir = '',
this.filenameFormat = '{artist} - {title}',
this.audioQuality = 'LOSSLESS',
this.autoFallback = true,
this.concurrentDownloads = 1,
});
DownloadQueueState copyWith({
@@ -94,7 +206,9 @@ class DownloadQueueState {
bool? isProcessing,
String? outputDir,
String? filenameFormat,
String? audioQuality,
bool? autoFallback,
int? concurrentDownloads,
}) {
return DownloadQueueState(
items: items ?? this.items,
@@ -102,18 +216,23 @@ class DownloadQueueState {
isProcessing: isProcessing ?? this.isProcessing,
outputDir: outputDir ?? this.outputDir,
filenameFormat: filenameFormat ?? this.filenameFormat,
audioQuality: audioQuality ?? this.audioQuality,
autoFallback: autoFallback ?? this.autoFallback,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
);
}
int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length;
int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length;
int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length;
int get activeDownloadsCount => items.where((i) => i.status == DownloadStatus.downloading).length;
}
// Download Queue Notifier (Riverpod 3.x)
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup
static const _cleanupInterval = 50; // Cleanup every 50 downloads
@override
DownloadQueueState build() {
@@ -203,11 +322,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
filenameFormat: settings.filenameFormat,
audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback,
concurrentDownloads: settings.concurrentDownloads,
);
}
String addToQueue(Track track, String service) {
// Sync settings before adding to queue
final settings = ref.read(settingsProvider);
updateSettings(settings);
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
final item = DownloadItem(
id: id,
@@ -227,6 +352,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void addMultipleToQueue(List<Track> tracks, String service) {
// Sync settings before adding to queue
final settings = ref.read(settingsProvider);
updateSettings(settings);
final newItems = tracks.map((track) {
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
return DownloadItem(
@@ -371,7 +500,34 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
print('[DownloadQueue] Output directory: ${state.outputDir}');
print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}');
// Use parallel processing if concurrentDownloads > 1
if (state.concurrentDownloads > 1) {
await _processQueueParallel();
} else {
await _processQueueSequential();
}
_stopProgressPolling();
// Final cleanup after queue finishes
if (_downloadCount > 0) {
print('[DownloadQueue] Final connection cleanup...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Final cleanup failed: $e');
}
_downloadCount = 0;
}
print('[DownloadQueue] Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
}
/// Sequential download processing (original behavior)
Future<void> _processQueueSequential() async {
while (true) {
final nextItem = state.items.firstWhere(
(item) => item.status == DownloadStatus.queued,
@@ -388,130 +544,202 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
break;
}
print('[DownloadQueue] Processing: ${nextItem.track.name} by ${nextItem.track.artistName}');
print('[DownloadQueue] Cover URL: ${nextItem.track.coverUrl}');
await _downloadSingleItem(nextItem);
}
}
/// Parallel download processing with worker pool
Future<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
while (true) {
// Get queued items
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
state = state.copyWith(currentDownload: nextItem);
updateItemStatus(nextItem.id, DownloadStatus.downloading);
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
print('[DownloadQueue] No more items to process');
break;
}
// Start progress polling
_startProgressPolling(nextItem.id);
try {
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
result = await PlatformBridge.downloadWithFallback(
isrc: nextItem.track.isrc ?? '',
spotifyId: nextItem.track.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
albumArtist: nextItem.track.albumArtist,
coverUrl: nextItem.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: nextItem.track.trackNumber ?? 1,
discNumber: nextItem.track.discNumber ?? 1,
releaseDate: nextItem.track.releaseDate,
preferredService: nextItem.service,
);
} else {
result = await PlatformBridge.downloadTrack(
isrc: nextItem.track.isrc ?? '',
service: nextItem.service,
spotifyId: nextItem.track.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
albumArtist: nextItem.track.albumArtist,
coverUrl: nextItem.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: nextItem.track.trackNumber ?? 1,
discNumber: nextItem.track.discNumber ?? 1,
releaseDate: nextItem.track.releaseDate,
);
}
// Stop progress polling for this item
_stopProgressPolling();
// Start new downloads up to max concurrent limit
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
final item = queuedItems.removeAt(0);
print('[DownloadQueue] Result: $result');
// Mark as downloading immediately to prevent double-processing
updateItemStatus(item.id, DownloadStatus.downloading);
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
print('[DownloadQueue] Download success, file: $filePath');
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
print('[DownloadQueue] Converting M4A to FLAC...');
updateItemStatus(nextItem.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
print('[DownloadQueue] Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
nextItem.track,
);
print('[DownloadQueue] Metadata and cover embedded successfully');
} catch (e) {
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
}
}
}
updateItemStatus(
nextItem.id,
DownloadStatus.completed,
progress: 1.0,
filePath: filePath,
);
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
id: nextItem.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
coverUrl: nextItem.track.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? nextItem.service,
downloadedAt: DateTime.now(),
),
);
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
print('[DownloadQueue] Download failed: $errorMsg');
updateItemStatus(
nextItem.id,
DownloadStatus.failed,
error: errorMsg,
);
}
} catch (e, stackTrace) {
_stopProgressPolling();
print('[DownloadQueue] Exception: $e');
print('[DownloadQueue] StackTrace: $stackTrace');
updateItemStatus(
nextItem.id,
DownloadStatus.failed,
error: e.toString(),
);
// Create the download future
final future = _downloadSingleItem(item).whenComplete(() {
activeDownloads.remove(item.id);
});
activeDownloads[item.id] = future;
print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
}
// Wait for at least one download to complete before checking for more
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
}
}
// Wait for all remaining downloads to complete
if (activeDownloads.isNotEmpty) {
await Future.wait(activeDownloads.values);
}
}
_stopProgressPolling();
print('[DownloadQueue] Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
/// Download a single item (used by both sequential and parallel processing)
Future<void> _downloadSingleItem(DownloadItem item) async {
print('[DownloadQueue] Processing: ${item.track.name} by ${item.track.artistName}');
print('[DownloadQueue] Cover URL: ${item.track.coverUrl}');
// Only set currentDownload for sequential mode (for progress polling)
if (state.concurrentDownloads == 1) {
state = state.copyWith(currentDownload: item);
_startProgressPolling(item.id);
}
updateItemStatus(item.id, DownloadStatus.downloading);
try {
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
print('[DownloadQueue] Quality: ${state.audioQuality}');
result = await PlatformBridge.downloadWithFallback(
isrc: item.track.isrc ?? '',
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
preferredService: item.service,
);
} else {
result = await PlatformBridge.downloadTrack(
isrc: item.track.isrc ?? '',
service: item.service,
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
);
}
// Stop progress polling for this item (sequential mode only)
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
print('[DownloadQueue] Result: $result');
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
print('[DownloadQueue] Download success, file: $filePath');
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
print('[DownloadQueue] Converting M4A to FLAC...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
print('[DownloadQueue] Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
item.track,
);
print('[DownloadQueue] Metadata and cover embedded successfully');
} catch (e) {
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
}
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
progress: 1.0,
filePath: filePath,
);
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
// Additional metadata
isrc: item.track.isrc,
spotifyId: item.track.id,
trackNumber: item.track.trackNumber,
discNumber: item.track.discNumber,
duration: item.track.duration,
releaseDate: item.track.releaseDate,
quality: state.audioQuality,
),
);
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
print('[DownloadQueue] Download failed: $errorMsg');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
);
}
// Increment download counter and cleanup connections periodically
_downloadCount++;
if (_downloadCount % _cleanupInterval == 0) {
print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Connection cleanup failed: $e');
}
}
} catch (e, stackTrace) {
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
print('[DownloadQueue] Exception: $e');
print('[DownloadQueue] StackTrace: $stackTrace');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: e.toString(),
);
}
}
}
+12
View File
@@ -64,6 +64,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(isFirstLaunch: false);
_saveSettings();
}
void setConcurrentDownloads(int count) {
// Clamp between 1 and 3
final clamped = count.clamp(1, 3);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
}
void setCheckForUpdates(bool enabled) {
state = state.copyWith(checkForUpdates: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
-372
View File
@@ -1,372 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class HistoryScreen extends ConsumerWidget {
const HistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyState = ref.watch(downloadHistoryProvider);
final history = historyState.items;
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Download History'),
actions: [
if (history.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => _showClearHistoryDialog(context, ref),
tooltip: 'Clear history',
),
],
),
body: history.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: history.length,
itemBuilder: (context, index) {
final item = history[index];
return _buildHistoryItem(context, ref, item, colorScheme);
},
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No download history',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Downloaded tracks will appear here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
final fileExists = File(item.filePath).existsSync();
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
color: colorScheme.error,
child: Icon(Icons.delete, color: colorScheme.onError),
),
onDismissed: (_) {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed "${item.trackName}" from history')),
);
},
child: ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
),
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Row(
children: [
Icon(
_getServiceIcon(item.service),
size: 12,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_formatDate(item.downloadedAt),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (!fileExists) ...[
const SizedBox(width: 8),
Icon(
Icons.warning,
size: 12,
color: colorScheme.error,
),
const SizedBox(width: 2),
Text(
'File missing',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
],
),
trailing: fileExists
? IconButton(
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
onPressed: () => _openFile(context, item.filePath),
)
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
),
);
}
IconData _getServiceIcon(String service) {
switch (service.toLowerCase()) {
case 'tidal':
return Icons.waves;
case 'qobuz':
return Icons.album;
case 'amazon':
return Icons.shopping_cart;
default:
return Icons.cloud_download;
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
if (diff.inHours == 0) {
return '${diff.inMinutes}m ago';
}
return '${diff.inHours}h ago';
} else if (diff.inDays == 1) {
return 'Yesterday';
} else if (diff.inDays < 7) {
return '${diff.inDays}d ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
Future<void> _openFile(BuildContext context, String filePath) async {
try {
final result = await OpenFilex.open(filePath);
if (result.type != ResultType.done) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cannot open: ${result.message}'),
action: SnackBarAction(
label: 'Copy Path',
onPressed: () {
Clipboard.setData(ClipboardData(text: filePath));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Path copied to clipboard')),
);
},
),
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 64,
height: 64,
fit: BoxFit.cover,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trackName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
item.artistName,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Text(
item.albumName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(),
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context);
},
icon: Icon(Icons.delete, color: colorScheme.error),
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
),
if (File(item.filePath).existsSync())
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_openFile(context, item.filePath);
},
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
),
],
),
],
),
),
);
}
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: isPath ? 12 : 14,
fontFamily: isPath ? 'monospace' : null,
),
),
),
],
),
);
}
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text(
'Are you sure you want to clear all download history? '
'This will not delete the downloaded files.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
-388
View File
@@ -1,388 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class HistoryTab extends ConsumerWidget {
const HistoryTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyState = ref.watch(downloadHistoryProvider);
final history = historyState.items;
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
// Header with clear action
if (history.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${history.length} downloads',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
TextButton.icon(
onPressed: () => _showClearHistoryDialog(context, ref),
icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error),
label: Text('Clear history', style: TextStyle(color: colorScheme.error)),
),
],
),
),
// History list
Expanded(
child: history.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: history.length,
itemBuilder: (context, index) {
final item = history[index];
return _buildHistoryItem(context, ref, item, colorScheme);
},
),
),
],
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No download history',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Downloaded tracks will appear here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
final fileExists = File(item.filePath).existsSync();
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
color: colorScheme.error,
child: Icon(Icons.delete, color: colorScheme.onError),
),
onDismissed: (_) {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed "${item.trackName}" from history')),
);
},
child: ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
),
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Row(
children: [
Icon(
_getServiceIcon(item.service),
size: 12,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_formatDate(item.downloadedAt),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (!fileExists) ...[
const SizedBox(width: 8),
Icon(
Icons.warning,
size: 12,
color: colorScheme.error,
),
const SizedBox(width: 2),
Text(
'File missing',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
],
),
trailing: fileExists
? IconButton(
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
onPressed: () => _openFile(context, item.filePath),
)
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
),
);
}
IconData _getServiceIcon(String service) {
switch (service.toLowerCase()) {
case 'tidal':
return Icons.waves;
case 'qobuz':
return Icons.album;
case 'amazon':
return Icons.shopping_cart;
default:
return Icons.cloud_download;
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
if (diff.inHours == 0) {
return '${diff.inMinutes}m ago';
}
return '${diff.inHours}h ago';
} else if (diff.inDays == 1) {
return 'Yesterday';
} else if (diff.inDays < 7) {
return '${diff.inDays}d ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
Future<void> _openFile(BuildContext context, String filePath) async {
try {
final result = await OpenFilex.open(filePath);
if (result.type != ResultType.done) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cannot open: ${result.message}'),
action: SnackBarAction(
label: 'Copy Path',
onPressed: () {
Clipboard.setData(ClipboardData(text: filePath));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Path copied to clipboard')),
);
},
),
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 64,
height: 64,
fit: BoxFit.cover,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trackName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
item.artistName,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Text(
item.albumName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(),
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context);
},
icon: Icon(Icons.delete, color: colorScheme.error),
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
),
if (File(item.filePath).existsSync())
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_openFile(context, item.filePath);
},
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
),
],
),
],
),
),
);
}
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: isPath ? 12 : 14,
fontFamily: isPath ? 'monospace' : null,
),
),
),
],
),
);
}
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text(
'Are you sure you want to clear all download history? '
'This will not delete the downloaded files.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
+85 -21
View File
@@ -7,6 +7,7 @@ import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
class HomeTab extends ConsumerStatefulWidget {
const HomeTab({super.key});
@@ -326,25 +327,28 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final fileExists = File(item.filePath).existsSync();
return ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
leading: Hero(
tag: 'cover_${item.id}',
child: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
fit: BoxFit.cover,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
),
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.artistName,
@@ -358,7 +362,26 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
onPressed: () => _openFile(item.filePath),
)
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
onTap: fileExists ? () => _openFile(item.filePath) : null,
// Tap to show metadata details
onTap: () => _navigateToMetadataScreen(item),
);
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);
}
@@ -443,10 +466,51 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
child: ListView.builder(
controller: scrollController,
itemCount: historyState.items.length,
itemBuilder: (context, index) => _buildHistoryTile(
historyState.items[index],
colorScheme,
),
itemBuilder: (context, index) {
final item = historyState.items[index];
final fileExists = File(item.filePath).existsSync();
return ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: fileExists
? IconButton(
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
onPressed: () => _openFile(item.filePath),
)
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
onTap: () {
Navigator.pop(context); // Close bottom sheet first
Future.delayed(const Duration(milliseconds: 100), () {
_navigateToMetadataScreen(item);
});
},
);
},
),
),
],
+49 -13
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings_tab.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@@ -15,11 +18,43 @@ class MainShell extends ConsumerStatefulWidget {
class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
late PageController _pageController;
bool _hasCheckedUpdate = false;
bool _isAnimating = false;
// Cache tab widgets to prevent rebuilds
final List<Widget> _tabs = const [
HomeTab(),
QueueTab(),
SettingsTab(),
];
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
// Check for updates after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
});
}
Future<void> _checkForUpdates() async {
if (_hasCheckedUpdate) return;
_hasCheckedUpdate = true;
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
final updateInfo = await UpdateChecker.checkForUpdate();
if (updateInfo != null && mounted) {
showUpdateDialog(
context,
updateInfo: updateInfo,
onDisableUpdates: () {
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
},
);
}
}
@override
@@ -29,16 +64,21 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _onNavTap(int index) {
setState(() => _currentIndex = index);
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
if (_currentIndex != index && !_isAnimating) {
_isAnimating = true;
setState(() => _currentIndex = index);
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
).then((_) => _isAnimating = false);
}
}
void _onPageChanged(int index) {
setState(() => _currentIndex = index);
if (_currentIndex != index) {
setState(() => _currentIndex = index);
}
}
@override
@@ -63,12 +103,8 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const BouncingScrollPhysics(),
children: const [
HomeTab(),
QueueTab(),
SettingsTab(),
],
physics: const ClampingScrollPhysics(),
children: _tabs,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
+157 -55
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
@@ -54,9 +56,6 @@ class SettingsScreen extends ConsumerWidget {
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
// Theme Preview
_buildThemePreview(context, colorScheme),
const Divider(),
// Download Section
@@ -125,6 +124,54 @@ class SettingsScreen extends ConsumerWidget {
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
@@ -132,19 +179,64 @@ class SettingsScreen extends ConsumerWidget {
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: const Text('SpotiFLAC v1.0.0'),
onTap: () => showAboutDialog(
context: context,
applicationName: 'SpotiFLAC',
applicationVersion: '1.0.0',
applicationLegalese: '© 2024 SpotiFLAC',
),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -158,51 +250,6 @@ class SettingsScreen extends ConsumerWidget {
);
}
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme Preview',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
],
),
],
),
),
),
);
}
Widget _buildColorChip(String label, Color background, Color foreground) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(16),
),
child: Text(
label,
style: TextStyle(color: foreground, fontSize: 12),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
@@ -423,4 +470,59 @@ class SettingsScreen extends ConsumerWidget {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+162 -49
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
@@ -61,9 +63,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
// Theme Preview
_buildThemePreview(context, colorScheme),
const Divider(),
// Download Section
@@ -132,6 +131,54 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
@@ -139,13 +186,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: const Text('SpotiFLAC v1.0.0'),
onTap: () => showAboutDialog(
context: context,
applicationName: 'SpotiFLAC',
applicationVersion: '1.0.0',
applicationLegalese: '© 2024 SpotiFLAC',
),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
// Bottom padding for navigation bar
@@ -154,6 +196,56 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -167,42 +259,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
);
}
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Theme Preview', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
],
),
],
),
),
),
);
}
Widget _buildColorChip(String label, Color background, Color foreground) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
child: Text(label, style: TextStyle(color: foreground, fontSize: 12)),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
@@ -222,8 +278,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (Lossless)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
default: return quality;
}
}
@@ -334,7 +391,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
mainAxisSize: MainAxisSize.min,
children: [
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
@@ -392,4 +450,59 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+7 -4
View File
@@ -314,10 +314,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStepDot(0, 'Permission', colorScheme),
Container(
width: 40,
height: 2,
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
Padding(
padding: const EdgeInsets.only(bottom: 20), // Offset for label height
child: Container(
width: 40,
height: 2,
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
),
),
_buildStepDot(1, 'Folder', colorScheme),
],
+793
View File
@@ -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);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
/// FFmpeg service for audio conversion and remuxing
class FFmpegService {
+10
View File
@@ -47,6 +47,7 @@ class PlatformBridge {
String? coverUrl,
required String outputDir,
required String filenameFormat,
String quality = 'LOSSLESS',
bool embedLyrics = true,
bool embedMaxQualityCover = true,
int trackNumber = 1,
@@ -65,6 +66,7 @@ class PlatformBridge {
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'track_number': trackNumber,
@@ -88,6 +90,7 @@ class PlatformBridge {
String? coverUrl,
required String outputDir,
required String filenameFormat,
String quality = 'LOSSLESS',
bool embedLyrics = true,
bool embedMaxQualityCover = true,
int trackNumber = 1,
@@ -107,6 +110,7 @@ class PlatformBridge {
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'track_number': trackNumber,
@@ -195,4 +199,10 @@ class PlatformBridge {
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Cleanup idle HTTP connections to prevent TCP exhaustion
/// Call this periodically during large batch downloads
static Future<void> cleanupConnections() async {
await _channel.invokeMethod('cleanupConnections');
}
}
+88
View File
@@ -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;
}
+162
View File
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/update_checker.dart';
class UpdateDialog extends StatelessWidget {
final UpdateInfo updateInfo;
final VoidCallback onDismiss;
final VoidCallback onDisableUpdates;
const UpdateDialog({
super.key,
required this.updateInfo,
required this.onDismiss,
required this.onDisableUpdates,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
title: Row(
children: [
Icon(Icons.system_update, color: colorScheme.primary),
const SizedBox(width: 12),
const Text('Update Available'),
],
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Version info
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Text(
'v${AppInfo.version}',
style: TextStyle(color: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 8),
Text(
'v${updateInfo.version}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
// Changelog header
Text(
'What\'s New:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Changelog content (scrollable)
Flexible(
child: Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Text(
_formatChangelog(updateInfo.changelog),
style: Theme.of(context).textTheme.bodySmall,
),
),
),
),
],
),
),
actions: [
// Don't remind again button
TextButton(
onPressed: () {
onDisableUpdates();
Navigator.pop(context);
},
child: Text(
'Don\'t remind',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
// Later button
TextButton(
onPressed: () {
onDismiss();
Navigator.pop(context);
},
child: const Text('Later'),
),
// Download button
FilledButton(
onPressed: () async {
final uri = Uri.parse(updateInfo.downloadUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
if (context.mounted) {
Navigator.pop(context);
}
},
child: const Text('Download'),
),
],
);
}
/// Format changelog - clean up markdown
String _formatChangelog(String changelog) {
// Remove markdown headers but keep content
var formatted = changelog
.replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '')
.replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // Remove bold
.replaceAll(RegExp(r'`([^`]+)`'), r'$1') // Remove code
.trim();
// Limit length
if (formatted.length > 1000) {
formatted = '${formatted.substring(0, 1000)}...';
}
return formatted;
}
}
/// Show update dialog
Future<void> showUpdateDialog(
BuildContext context, {
required UpdateInfo updateInfo,
required VoidCallback onDisableUpdates,
}) async {
return showDialog(
context: context,
builder: (context) => UpdateDialog(
updateInfo: updateInfo,
onDismiss: () {},
onDisableUpdates: onDisableUpdates,
),
);
}
+4 -4
View File
@@ -313,14 +313,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
ffmpeg_kit_flutter_new:
ffmpeg_kit_flutter_new_audio:
dependency: "direct main"
description:
name: ffmpeg_kit_flutter_new
sha256: d127635f27e93a7f21f0a14ce0a1a148e80919c402dac4a2118d73bfb17ce841
name: ffmpeg_kit_flutter_new_audio
sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "2.0.0"
ffmpeg_kit_flutter_platform_interface:
dependency: transitive
description:
+3 -4
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 1.0.0+1
version: 1.2.0+10
environment:
sdk: ^3.10.0
@@ -48,8 +48,8 @@ dependencies:
device_info_plus: ^12.3.0
share_plus: ^10.1.4
# FFmpeg for audio conversion
ffmpeg_kit_flutter_new: ^4.1.0
# FFmpeg for audio conversion (audio-only version - much smaller)
ffmpeg_kit_flutter_new_audio: ^2.0.0
open_filex: ^4.7.0
dev_dependencies:
@@ -75,4 +75,3 @@ flutter:
assets:
- assets/images/
- assets/icons/
-30
View File
@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}