Compare commits

...

11 Commits

Author SHA1 Message Date
zarzet 5c6bf02f1c v2.0.1: Unified progress tracking, quality picker consistency, notification fixes 2026-01-03 05:43:14 +07:00
zarzet 852335f794 fix: correct version to 2.0.0 (remove preview suffix) 2026-01-03 04:37:48 +07:00
zarzet b87de1f00a feat: quality picker with track info, update dialog redesign, finalizing notification fix
- Quality picker now shows track name, artist, and cover
- Tap to expand long track titles (icon only shows when truncated)
- Ripple effect follows rounded corners including drag handle
- Update dialog redesigned with Material Expressive 3 style
- Fixed update notification stuck at 100% after download complete
- Ask before download now enabled by default
- Finalizing notification for multi-progress polling
2026-01-03 04:26:19 +07:00
zarzet 8fcb389bb2 fix: play button red flash on app start
Use optimistic rendering for file existence check - assume file exists while async check runs, only show error if file is actually missing
2026-01-03 00:52:34 +07:00
zarzet 08bca30fcd perf: optimize state management, add HTTPS validation, improve UI performance
- Add HTTPS-only validation for APK downloads and update checks
- Use .select() for Riverpod providers to prevent unnecessary rebuilds
- Add keys to all list builders for efficient updates
- Implement request cancellation for outdated API requests
- Debounce all network requests (URLs and searches)
- Limit file existence cache to 500 entries
- Add ref.onDispose for timer cleanup
- Add error handling for share intent stream
- Redesign About page with Material Expressive 3 style
- Rename Search tab to Home
- Remove Features section from README
2026-01-03 00:46:34 +07:00
zarzet a7c5afdd20 ui: redesign About page with contributors and fix title alignment 2026-01-02 20:15:29 +07:00
zarzet 5eac386eba ui: remove Search Music text, keep only logo 2026-01-02 18:30:57 +07:00
zarzet d35d60ac7d docs: update screenshots 2026-01-02 18:28:38 +07:00
zarzet 7c43d4bf70 docs: add active development notice 2026-01-02 18:21:07 +07:00
zarzet 2043370b6c feat: background download service + queue persistence (v1.6.1)
- Add foreground service for background downloads with wake lock
- Persist download queue to SharedPreferences for app restart recovery
- Fix share intent causing app restart (singleTask + onNewIntent)
- Fix back button clearing state during loading
- Upgrade Kotlin to 2.3.0 for share_plus 12.0.1 compatibility
- Add WAKE_LOCK permission for foreground service
2026-01-02 18:14:19 +07:00
zarzet 39ddb7a14f fix: persist download queue to survive app restart (v1.6.1)
- Download queue now persisted to SharedPreferences
- Auto-restore pending downloads on app restart
- Interrupted downloads reset to queued and auto-resumed
- singleTask launch mode to prevent app restart on share intent
- onNewIntent handler for proper intent handling
- Reverted share_plus to 10.1.4 (12.0.1 has Kotlin build issues)
2026-01-02 17:35:34 +07:00
47 changed files with 3765 additions and 1051 deletions
+15 -3
View File
@@ -17,14 +17,26 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
steps:
- name: Get version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
VERSION="${{ github.event.inputs.version }}"
else
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Detected pre-release version: $VERSION"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Detected stable version: $VERSION"
fi
# Android and iOS build in PARALLEL
@@ -316,6 +328,6 @@ jobs:
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
prerelease: false
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+140
View File
@@ -1,5 +1,145 @@
# Changelog
## [2.0.1] - 2026-01-03
### Added
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
- Tap to expand long track titles
- Expand icon only shows when title is truncated
- Ripple effect follows rounded corners including drag handle
### Changed
- **Update Dialog Redesign**: Material Expressive 3 style
- Icon header with container
- Version chips with "Current" and "New" labels
- Changelog in rounded card
- Download progress with percentage indicator
- Cleaner button layout
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
- All downloads now use item-based progress tracking
- Fixes duplicate notification bug when finalizing
- Cleaner codebase with single progress system
### Fixed
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
## [2.0.0] - 2026-01-03
### Added
- **Artist Search Results**: Search now shows artists alongside tracks
- Horizontal scrollable artist cards with circular avatars
- Tap artist to view their discography
- **Multi-Layer Caching System**: Aggressive caching to minimize API calls
- Go backend cache: Artist (10 min), Album (10 min), Search (5 min)
- Flutter memory cache: Instant navigation for previously viewed artists/albums
- Duplicate search prevention: Same query won't trigger new API call
- **Real-time Download Status**: Track items show live download progress
- Queued: Hourglass icon
- Downloading: Circular progress with percentage
- Completed: Check icon
- Works in Home search, Album, and Playlist screens
- **Downloaded Track Indicator**: Tracks already in history show check mark
- Lazy file verification: Only checks file existence when tapped
- Auto-removes from history if file was deleted, allowing re-download
- Prevents accidental duplicate downloads
- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags
- Stable users won't receive update notifications for preview versions
### Changed
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
- Header (name, cover) shows instantly from available data
- Content (albums/tracks) loads in background inside the screen
- Second visit to same artist/album is instant from Flutter cache
- **Search Results UI Redesign**:
- Removed "Download All" button from search results
- Added "Songs" section header (matches "Artists" header style)
- Track list now in grouped card with rounded corners (like Settings)
- Track items with dividers and InkWell ripple effect
- **Larger UI Elements**: Improved touch targets and visual hierarchy
- Recent downloads: Album art 56→100px, section height 80→130px
- Artist cards: Avatar 72→88px, container 90→100px
- Track items: Album art 48→56px
- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search
- **Smoother Progress Animation**: Progress jumps to 100% after download completes
- Embedding (cover, metadata, lyrics) happens in background without blocking UI
- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata
- Distinct icon (edit_note) with tertiary color
- User knows download is complete, just processing metadata
- **Consistent Download Button Sizes**: All download/status buttons now 44x44px
- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color
- Settings cards use overlay colors for better contrast
- Theme/view mode chips have visible borders in light mode
- **Navigation Bar Styling**: Distinct background color from content area
- **Ask Before Download Default**: Now enabled by default for better UX
### Fixed
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
- Uses Riverpod `select()` for granular state watching
- Prevents entire list rebuild on progress updates
- **Update Notification Stuck**: Fixed notification staying at 100% after download complete
## [1.6.3] - 2026-01-03
### Added
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
- Collapsing header with cover art and gradient overlay
- Card-based info section with rounded corners (20px radius)
- Tonal download buttons with circular shape
- Quality picker bottom sheet with drag handle
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
### Changed
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
- Enables native predictive back gesture animations
- Search results stay on Home tab for quick downloads
- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation
## [1.6.2] - 2026-01-02
### Added
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
### Changed
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
### Fixed
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
### Performance
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
- **Stream Error Handling**: Share intent stream now has proper error handling
## [1.6.1] - 2026-01-02
### Added
- **Background Download Service**: Downloads now continue running when app is in background
- Foreground service with wake lock prevents Android from killing downloads
- Persistent notification shows download progress
- No more "connection abort" errors when switching apps
### Fixed
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
- Download queue is now persisted to storage and automatically restored on app restart
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
- Changed launch mode to `singleTask` to reuse existing activity instead of restarting
- Added `onNewIntent` handler to properly receive new share intents
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
### Changed
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
## [1.6.0] - 2026-01-02
### Added
+3 -11
View File
@@ -11,18 +11,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
</div>
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Features
- Download tracks, albums, and playlists from Spotify links
- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
- Material Expressive 3 design with dynamic colors
- High performance rendering with Impeller (Vulkan)
- Concurrent downloads up to 3 simultaneous
- Real-time download progress tracking
- Download notifications
## Screenshots
<p align="center">
@@ -39,7 +31,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
## Disclaimer
> **📱 iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
+4 -3
View File
@@ -12,19 +12,20 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="SpotiFLAC"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -5,83 +5,211 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
/**
* Foreground service to keep downloads running when app is in background.
* This prevents Android from killing the download process or throttling network.
*/
class DownloadService : Service() {
companion object {
const val CHANNEL_ID = "spotiflac_download_channel"
const val NOTIFICATION_ID = 1
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
private const val CHANNEL_ID = "download_channel"
private const val NOTIFICATION_ID = 1001
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
const val EXTRA_TRACK_NAME = "track_name"
const val EXTRA_ARTIST_NAME = "artist_name"
const val EXTRA_PROGRESS = "progress"
const val EXTRA_TOTAL = "total"
const val EXTRA_QUEUE_COUNT = "queue_count"
private var isRunning = false
fun isServiceRunning(): Boolean = isRunning
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
val intent = Intent(context, DownloadService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_TRACK_NAME, trackName)
putExtra(EXTRA_ARTIST_NAME, artistName)
putExtra(EXTRA_QUEUE_COUNT, queueCount)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, DownloadService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
val intent = Intent(context, DownloadService::class.java).apply {
action = ACTION_UPDATE_PROGRESS
putExtra(EXTRA_TRACK_NAME, trackName)
putExtra(EXTRA_ARTIST_NAME, artistName)
putExtra(EXTRA_PROGRESS, progress)
putExtra(EXTRA_TOTAL, total)
putExtra(EXTRA_QUEUE_COUNT, queueCount)
}
context.startService(intent)
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var currentTrackName = ""
private var currentArtistName = ""
private var queueCount = 0
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startForegroundService()
ACTION_STOP -> stopSelf()
ACTION_START -> {
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
startForegroundService()
}
ACTION_STOP -> {
stopForegroundService()
}
ACTION_UPDATE_PROGRESS -> {
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
val total = intent.getLongExtra(EXTRA_TOTAL, 0)
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
updateNotification(progress, total)
}
}
return START_NOT_STICKY
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Download Progress",
"Download Service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows download progress for SpotiFLAC"
description = "Shows download progress"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun startForegroundService() {
val notification = createNotification("Downloading...", 0)
isRunning = true
// Acquire wake lock to prevent CPU sleep
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG
).apply {
acquire(60 * 60 * 1000L) // 1 hour max
}
val notification = buildNotification(0, 0)
startForeground(NOTIFICATION_ID, notification)
}
fun updateProgress(trackName: String, progress: Int) {
val notification = createNotification(trackName, progress)
private fun stopForegroundService() {
isRunning = false
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun updateNotification(progress: Long, total: Long) {
if (!isRunning) return
val notification = buildNotification(progress, total)
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, notification)
}
private fun createNotification(title: String, progress: Int): Notification {
val intent = Intent(this, MainActivity::class.java)
private fun buildNotification(progress: Long, total: Long): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = Intent(this, DownloadService::class.java).apply {
action = ACTION_STOP
val title = if (queueCount > 1) {
"Downloading $queueCount tracks"
} else if (currentTrackName.isNotEmpty()) {
currentTrackName
} else {
"Downloading..."
}
val stopPendingIntent = PendingIntent.getService(
this, 0, stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("SpotiFLAC")
.setContentText(title)
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
currentArtistName
} else if (total > 0) {
val progressPercent = (progress * 100 / total).toInt()
val progressMB = progress / (1024.0 * 1024.0)
val totalMB = total / (1024.0 * 1024.0)
String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
} else {
"Preparing download..."
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setProgress(100, progress, progress == 0)
.setOngoing(true)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
.build()
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
if (total > 0) {
builder.setProgress(100, (progress * 100 / total).toInt(), false)
} else {
builder.setProgress(0, 0, true)
}
return builder.build()
}
override fun onDestroy() {
isRunning = false
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
super.onDestroy()
}
}
@@ -1,5 +1,6 @@
package com.zarz.spotiflac
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -14,6 +15,12 @@ class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -43,6 +50,15 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"searchSpotifyAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 3
val response = withContext(Dispatchers.IO) {
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
}
result.success(response)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -160,6 +176,29 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"startDownloadService" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val queueCount = call.argument<Int>("queue_count") ?: 0
DownloadService.start(this@MainActivity, trackName, artistName, queueCount)
result.success(null)
}
"stopDownloadService" -> {
DownloadService.stop(this@MainActivity)
result.success(null)
}
"updateDownloadServiceProgress" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val progress = call.argument<Long>("progress") ?: 0L
val total = call.argument<Long>("total") ?: 0L
val queueCount = call.argument<Int>("queue_count") ?: 0
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
result.success(null)
}
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
else -> result.notImplemented()
}
} catch (e: Exception) {
+1 -1
View File
@@ -20,7 +20,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
}
include(":app")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 135 KiB

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

+13 -14
View File
@@ -203,12 +203,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -232,11 +227,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
}
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
out, err := os.Create(outputPath)
@@ -245,14 +237,14 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
}
defer out.Close()
// Use appropriate progress writer
// Use item progress writer
var bytesWritten int64
if itemID != "" {
pw := NewItemProgressWriter(out, itemID)
bytesWritten, err = io.Copy(pw, resp.Body)
} else {
pw := NewProgressWriter(out)
bytesWritten, err = io.Copy(pw, resp.Body)
// Fallback: direct copy without progress tracking
bytesWritten, err = io.Copy(out, resp.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
@@ -318,6 +310,13 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
return "", fmt.Errorf("download failed: %w", err)
}
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Log track info from DoubleDouble (for debugging)
if trackName != "" && artistName != "" {
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
+20
View File
@@ -70,6 +70,26 @@ func SearchSpotify(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
// SearchSpotifyAll searches for tracks and artists on Spotify
// Returns JSON with tracks and artists arrays
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := NewSpotifyMetadataClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// CheckAvailability checks track availability on streaming services
// Returns JSON with availability info for Tidal, Qobuz, Amazon
func CheckAvailability(spotifyID, isrc string) (string, error) {
+63 -114
View File
@@ -5,7 +5,8 @@ import (
"sync"
)
// DownloadProgress represents current download progress (legacy single download)
// DownloadProgress represents current download progress
// Now unified - returns data from multi-progress system
type DownloadProgress struct {
CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"`
@@ -13,6 +14,7 @@ type DownloadProgress struct {
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
}
// ItemProgress represents progress for a single download item
@@ -22,6 +24,7 @@ type ItemProgress struct {
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
}
// MultiProgress holds progress for multiple concurrent downloads
@@ -30,28 +33,40 @@ type MultiProgress struct {
}
var (
currentProgress DownloadProgress
progressMu sync.RWMutex
downloadDir string
downloadDirMu sync.RWMutex
// Multi-download progress tracking
downloadDir string
downloadDirMu sync.RWMutex
// Multi-download progress tracking (unified system)
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
)
// getProgress returns current download progress (legacy)
// getProgress returns current download progress from multi-progress system
// Returns first active item's progress for backward compatibility
func getProgress() DownloadProgress {
progressMu.RLock()
defer progressMu.RUnlock()
return currentProgress
multiMu.RLock()
defer multiMu.RUnlock()
// Find first active item
for _, item := range multiProgress.Items {
return DownloadProgress{
CurrentFile: item.ItemID,
Progress: item.Progress * 100, // Convert to percentage
BytesTotal: item.BytesTotal,
BytesReceived: item.BytesReceived,
IsDownloading: item.IsDownloading,
Status: item.Status,
}
}
return DownloadProgress{}
}
// GetMultiProgress returns progress for all active downloads as JSON
func GetMultiProgress() string {
multiMu.RLock()
defer multiMu.RUnlock()
jsonBytes, err := json.Marshal(multiProgress)
if err != nil {
return "{\"items\":{}}"
@@ -63,7 +78,7 @@ func GetMultiProgress() string {
func GetItemProgress(itemID string) string {
multiMu.RLock()
defer multiMu.RUnlock()
if item, ok := multiProgress.Items[itemID]; ok {
jsonBytes, _ := json.Marshal(item)
return string(jsonBytes)
@@ -75,13 +90,14 @@ func GetItemProgress(itemID string) string {
func StartItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
multiProgress.Items[itemID] = &ItemProgress{
ItemID: itemID,
BytesTotal: 0,
BytesReceived: 0,
Progress: 0,
IsDownloading: true,
Status: "downloading",
}
}
@@ -89,7 +105,7 @@ func StartItemProgress(itemID string) {
func SetItemBytesTotal(itemID string, total int64) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.BytesTotal = total
}
@@ -99,7 +115,7 @@ func SetItemBytesTotal(itemID string, total int64) {
func SetItemBytesReceived(itemID string, received int64) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.BytesReceived = received
if item.BytesTotal > 0 {
@@ -112,10 +128,38 @@ func SetItemBytesReceived(itemID string, received int64) {
func CompleteItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = 1.0
item.IsDownloading = false
item.Status = "completed"
}
}
// SetItemProgress sets progress for an item directly
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = progress
if bytesReceived > 0 {
item.BytesReceived = bytesReceived
}
if bytesTotal > 0 {
item.BytesTotal = bytesTotal
}
}
}
// SetItemFinalizing marks an item as finalizing (embedding metadata)
func SetItemFinalizing(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = 1.0
item.Status = "finalizing"
}
}
@@ -123,7 +167,7 @@ func CompleteItemProgress(itemID string) {
func RemoveItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
delete(multiProgress.Items, itemID)
}
@@ -131,45 +175,10 @@ func RemoveItemProgress(itemID string) {
func ClearAllItemProgress() {
multiMu.Lock()
defer multiMu.Unlock()
multiProgress.Items = make(map[string]*ItemProgress)
}
// Legacy functions for backward compatibility
// SetDownloadProgress sets the current download progress (MB downloaded)
func SetDownloadProgress(mbDownloaded float64) {
progressMu.Lock()
defer progressMu.Unlock()
currentProgress.Progress = mbDownloaded
currentProgress.IsDownloading = true
}
// SetDownloadSpeed sets the current download speed
func SetDownloadSpeed(speedMBps float64) {
progressMu.Lock()
defer progressMu.Unlock()
currentProgress.Speed = speedMBps
}
// SetCurrentFile sets the current file being downloaded and resets progress
func SetCurrentFile(filename string) {
progressMu.Lock()
defer progressMu.Unlock()
currentProgress.BytesReceived = 0
currentProgress.BytesTotal = 0
currentProgress.Progress = 0
currentProgress.CurrentFile = filename
currentProgress.IsDownloading = true
}
// ResetProgress resets the download progress
func ResetProgress() {
progressMu.Lock()
defer progressMu.Unlock()
currentProgress = DownloadProgress{}
}
// setDownloadDir sets the default download directory
func setDownloadDir(path string) error {
downloadDirMu.Lock()
@@ -185,64 +194,6 @@ func getDownloadDir() string {
return downloadDir
}
// SetDownloading sets the download status
func SetDownloading(status bool) {
progressMu.Lock()
defer progressMu.Unlock()
currentProgress.IsDownloading = status
}
// SetBytesTotal sets total bytes to download
func SetBytesTotal(total int64) {
progressMu.Lock()
defer progressMu.Unlock()
currentProgress.BytesTotal = total
}
// SetBytesReceived sets bytes received so far
func SetBytesReceived(received int64) {
progressMu.Lock()
defer progressMu.Unlock()
currentProgress.BytesReceived = received
if currentProgress.BytesTotal > 0 {
currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100
}
}
// ProgressWriter wraps io.Writer to track download progress (legacy single)
type ProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
total int64
current int64
}
// NewProgressWriter creates a new progress writer wrapping an io.Writer
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
SetBytesReceived(0)
return &ProgressWriter{
writer: w,
current: 0,
total: 0,
}
}
// Write implements io.Writer
func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
if err != nil {
return n, err
}
pw.current += int64(n)
pw.total += int64(n)
SetBytesReceived(pw.current)
return n, nil
}
// GetTotal returns total bytes written
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
@@ -267,7 +218,5 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
}
pw.current += int64(n)
SetItemBytesReceived(pw.itemID, pw.current)
// Also update legacy progress for backward compatibility
SetBytesReceived(pw.current)
return n, nil
}
+13 -14
View File
@@ -262,12 +262,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -289,11 +284,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
out, err := os.Create(outputPath)
@@ -302,13 +294,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
defer out.Close()
// Use appropriate progress writer
// Use item progress writer
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
}
return err
}
@@ -385,6 +377,13 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
return "", fmt.Errorf("download failed: %w", err)
}
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Embed metadata
metadata := Metadata{
Title: req.TrackName,
+185 -12
View File
@@ -10,6 +10,7 @@ import (
"math/rand"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -23,10 +24,25 @@ const (
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute
albumCacheTTL = 10 * time.Minute
)
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
// cacheEntry holds cached data with expiration
type cacheEntry struct {
data interface{}
expiresAt time.Time
}
func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt)
}
// SpotifyMetadataClient handles Spotify API interactions
type SpotifyMetadataClient struct {
httpClient *http.Client
@@ -34,31 +50,45 @@ type SpotifyMetadataClient struct {
clientSecret string
cachedToken string
tokenExpiresAt time.Time
tokenMu sync.Mutex // Protects token cache for concurrent access
rng *rand.Rand
rngMu sync.Mutex
userAgent string
// Caches to reduce API calls
artistCache map[string]*cacheEntry // key: artistID
searchCache map[string]*cacheEntry // key: query+type
albumCache map[string]*cacheEntry // key: albumID
cacheMu sync.RWMutex
}
// NewSpotifyMetadataClient creates a new Spotify client
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
// Decode credentials from base64
clientID := ""
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
clientID = string(decoded)
// Prefer environment variables for credentials (more secure), fall back to built-in
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
if clientID == "" {
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
clientID = string(decoded)
}
}
clientSecret := ""
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
clientSecret = string(decoded)
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientSecret == "" {
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
clientSecret = string(decoded)
}
}
c := &SpotifyMetadataClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
artistCache: make(map[string]*cacheEntry),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
}
c.userAgent = c.randomUserAgent()
return c
@@ -170,6 +200,21 @@ type SearchResult struct {
Total int `json:"total"`
}
// SearchArtistResult represents an artist in search results
type SearchArtistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Images string `json:"images"`
Followers int `json:"followers"`
Popularity int `json:"popularity"`
}
// SearchAllResult represents combined search results for tracks and artists
type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
}
type spotifyURI struct {
Type string
ID string
@@ -293,6 +338,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
return result, nil
}
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
// Create cache key
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*SearchAllResult), nil
}
c.cacheMu.RUnlock()
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, err
}
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
var response struct {
Tracks struct {
Items []trackFull `json:"items"`
} `json:"tracks"`
Artists struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
Followers struct {
Total int `json:"total"`
} `json:"followers"`
Popularity int `json:"popularity"`
} `json:"items"`
} `json:"artists"`
}
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
return nil, err
}
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
}
for _, track := range response.Tracks.Items {
result.Tracks = append(result.Tracks, TrackMetadata{
SpotifyID: track.ID,
Artists: joinArtists(track.Artists),
Name: track.Name,
AlbumName: track.Album.Name,
AlbumArtist: joinArtists(track.Album.Artists),
DurationMS: track.DurationMS,
Images: firstImageURL(track.Album.Images),
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TotalTracks: track.Album.TotalTracks,
DiscNumber: track.DiscNumber,
ExternalURL: track.ExternalURL.Spotify,
ISRC: track.ExternalID.ISRC,
})
}
// Limit artists to artistLimit
artistCount := len(response.Artists.Items)
if artistCount > artistLimit {
artistCount = artistLimit
}
for i := 0; i < artistCount; i++ {
artist := response.Artists.Items[i]
result.Artists = append(result.Artists, SearchArtistResult{
ID: artist.ID,
Name: artist.Name,
Images: firstImageURL(artist.Images),
Followers: artist.Followers.Total,
Popularity: artist.Popularity,
})
}
// Store in cache
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(searchCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
var data trackFull
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
@@ -319,6 +456,14 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
}
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumResponsePayload), nil
}
c.cacheMu.RUnlock()
var data struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
@@ -374,10 +519,20 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
})
}
return &AlbumResponsePayload{
result := &AlbumResponsePayload{
AlbumInfo: info,
TrackList: tracks,
}, nil
}
// Store in cache
c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(albumCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
@@ -436,6 +591,14 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*ArtistResponsePayload), nil
}
c.cacheMu.RUnlock()
// Fetch artist info
var artistData struct {
ID string `json:"id"`
@@ -511,10 +674,20 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
}
}
return &ArtistResponsePayload{
result := &ArtistResponsePayload{
ArtistInfo: artistInfo,
Albums: albums,
}, nil
}
// Store in cache
c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(artistCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
+19 -28
View File
@@ -646,12 +646,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
}
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -673,11 +668,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
out, err := os.Create(outputPath)
@@ -686,13 +678,13 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
defer out.Close()
// Use appropriate progress writer
// Use item progress writer
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
}
return err
}
@@ -709,12 +701,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
// If we have a direct URL (BTS format), download directly with progress tracking
if directURL != "" {
// Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
// Initialize item progress if itemID provided
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -736,11 +723,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
}
// Set total bytes for progress tracking
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
if itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
}
out, err := os.Create(outputPath)
@@ -749,13 +733,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
}
defer out.Close()
// Use appropriate progress writer
// Use item progress writer
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
progressWriter := NewProgressWriter(out)
_, err = io.Copy(progressWriter, resp.Body)
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
}
return err
}
@@ -905,6 +889,13 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
return "", fmt.Errorf("download failed: %w", err)
}
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Check if file was saved as M4A (DASH stream) instead of FLAC
// downloadFromManifest saves DASH streams as .m4a
actualOutputPath := outputPath
+9
View File
@@ -66,6 +66,15 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "searchSpotifyAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
if let error = error { throw error }
return response
case "checkAvailability":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
+3 -2
View File
@@ -1,10 +1,11 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '1.6.0';
static const String buildNumber = '25';
static const String version = '2.0.1';
static const String buildNumber = '31';
static const String fullVersion = '$version+$buildNumber';
static const String appName = 'SpotiFLAC';
static const String copyright = '© 2026 SpotiFLAC';
+1
View File
@@ -7,6 +7,7 @@ part 'download_item.g.dart';
enum DownloadStatus {
queued,
downloading,
finalizing, // Embedding metadata, cover, lyrics
completed,
failed,
skipped,
+1
View File
@@ -36,6 +36,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
const _$DownloadStatusEnumMap = {
DownloadStatus.queued: 'queued',
DownloadStatus.downloading: 'downloading',
DownloadStatus.finalizing: 'finalizing',
DownloadStatus.completed: 'completed',
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
+1 -1
View File
@@ -35,7 +35,7 @@ class AppSettings {
this.folderOrganization = 'none', // Default: no folder organization
this.convertLyricsToRomaji = false, // Default: keep original Japanese
this.historyViewMode = 'grid', // Default: grid view
this.askQualityBeforeDownload = false, // Default: use preset quality
this.askQualityBeforeDownload = true, // Default: ask quality before download
});
AppSettings copyWith({
+1 -1
View File
@@ -21,7 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
folderOrganization: json['folderOrganization'] as String? ?? 'none',
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false,
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
+220 -57
View File
@@ -99,8 +99,16 @@ class DownloadHistoryItem {
// Download History State
class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
const DownloadHistoryState({this.items = const []});
DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => item.spotifyId!)
.toSet();
/// Check if a track has been downloaded (by Spotify ID)
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId);
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
return DownloadHistoryState(items: items ?? this.items);
@@ -116,7 +124,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
DownloadHistoryState build() {
// Load history from storage on init
_loadFromStorageSync();
return const DownloadHistoryState();
return DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
@@ -173,8 +181,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_saveToStorage();
}
/// Remove item from history by Spotify ID
void removeBySpotifyId(String spotifyId) {
state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
);
_saveToStorage();
_historyLog.d('Removed item with spotifyId: $spotifyId');
}
/// Get history item by Spotify ID
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
}
void clearHistory() {
state = const DownloadHistoryState();
state = DownloadHistoryState();
_saveToStorage();
}
}
@@ -242,54 +264,99 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup
static const _cleanupInterval = 50; // Cleanup every 50 downloads
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
final NotificationService _notificationService = NotificationService();
int _totalQueuedAtStart = 0; // Track total items when queue started
int _completedInSession = 0; // Track completed downloads in current session
int _failedInSession = 0; // Track failed downloads in current session
bool _isLoaded = false;
@override
DownloadQueueState build() {
// Initialize output directory asynchronously
// Cleanup timer when provider is disposed
ref.onDispose(() {
_progressTimer?.cancel();
_progressTimer = null;
});
// Initialize output directory and load persisted queue asynchronously
Future.microtask(() async {
await _initOutputDir();
await _loadQueueFromStorage();
});
return const DownloadQueueState();
}
void _startProgressPolling(String itemId) {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
try {
final progress = await PlatformBridge.getDownloadProgress();
final bytesReceived = progress['bytes_received'] as int? ?? 0;
final bytesTotal = progress['bytes_total'] as int? ?? 0;
final isDownloading = progress['is_downloading'] as bool? ?? false;
/// Load persisted queue from storage (for app restart recovery)
Future<void> _loadQueueFromStorage() async {
if (_isLoaded) return;
_isLoaded = true;
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_queueStorageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList();
if (isDownloading && bytesTotal > 0) {
final percentage = bytesReceived / bytesTotal;
updateProgress(itemId, percentage);
// Update notification with progress
final currentItem = state.currentDownload;
if (currentItem != null) {
_notificationService.showDownloadProgress(
trackName: currentItem.track.name,
artistName: currentItem.track.artistName,
progress: bytesReceived,
total: bytesTotal,
);
// Reset downloading items to queued (they were interrupted)
final restoredItems = items.map((item) {
if (item.status == DownloadStatus.downloading) {
return item.copyWith(status: DownloadStatus.queued, progress: 0);
}
return item;
}).toList();
// Only restore queued/downloading items (not completed/failed/skipped)
final pendingItems = restoredItems.where((item) =>
item.status == DownloadStatus.queued
).toList();
if (pendingItems.isNotEmpty) {
state = state.copyWith(items: pendingItems);
_log.i('Restored ${pendingItems.length} pending items from storage');
// Log progress
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
_log.d('Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
// Auto-resume queue processing
Future.microtask(() => _processQueue());
} else {
_log.d('No pending items to restore');
// Clear storage since nothing to restore
await prefs.remove(_queueStorageKey);
}
} catch (e) {
// Ignore polling errors
} else {
_log.d('No queue found in storage');
}
});
} catch (e) {
_log.e('Failed to load queue from storage: $e');
}
}
/// Start multi-progress polling for concurrent downloads
/// Save current queue to storage (only pending items)
Future<void> _saveQueueToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Only persist queued and downloading items
final pendingItems = state.items.where((item) =>
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading
).toList();
if (pendingItems.isEmpty) {
// Clear storage if no pending items
await prefs.remove(_queueStorageKey);
_log.d('Cleared queue storage (no pending items)');
} else {
final jsonList = pendingItems.map((e) => e.toJson()).toList();
await prefs.setString(_queueStorageKey, jsonEncode(jsonList));
_log.d('Saved ${pendingItems.length} pending items to storage');
}
} catch (e) {
_log.e('Failed to save queue to storage: $e');
}
}
/// Start multi-progress polling for all downloads (sequential and parallel)
void _startMultiProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
@@ -297,12 +364,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
bool hasFinalizingItem = false;
String? finalizingTrackName;
String? finalizingArtistName;
for (final entry in items.entries) {
final itemId = entry.key;
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
// Check if status is "finalizing" (embedding metadata)
// Only trust finalizing status if bytesTotal > 0 (download actually happened)
if (status == 'finalizing' && bytesTotal > 0) {
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
// Track finalizing item for notification
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
if (currentItem != null) {
hasFinalizingItem = true;
finalizingTrackName = currentItem.track.name;
finalizingArtistName = currentItem.track.artistName;
}
continue;
}
if (isDownloading && bytesTotal > 0) {
final percentage = bytesReceived / bytesTotal;
@@ -315,22 +402,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
// Update notification with first active download
// Show finalizing notification if any item is finalizing (takes priority)
if (hasFinalizingItem && finalizingTrackName != null) {
_notificationService.showDownloadFinalizing(
trackName: finalizingTrackName,
artistName: finalizingArtistName ?? '',
);
return; // Don't show download progress notification
}
// Update notification with active downloads
if (items.isNotEmpty) {
final firstEntry = items.entries.first;
final firstProgress = firstEntry.value as Map<String, dynamic>;
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
// Find the item to get track info
// Find downloading items (not finalizing)
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
if (downloadingItems.isNotEmpty) {
// Show single track name if only 1 download, otherwise show count
final trackName = downloadingItems.length == 1
? downloadingItems.first.track.name
: '${downloadingItems.length} downloads';
final artistName = downloadingItems.length == 1
? downloadingItems.first.track.artistName
: 'Downloading...';
_notificationService.showDownloadProgress(
trackName: '${downloadingItems.length} downloads',
artistName: 'Downloading...',
trackName: trackName,
artistName: artistName,
progress: bytesReceived,
total: bytesTotal > 0 ? bytesTotal : 1,
);
// Update foreground service notification (Android)
if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress(
trackName: downloadingItems.first.track.name,
artistName: downloadingItems.first.track.artistName,
progress: bytesReceived,
total: bytesTotal > 0 ? bytesTotal : 1,
queueCount: state.queuedCount,
).catchError((_) {}); // Ignore errors
}
}
}
} catch (e) {
@@ -461,6 +576,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
state = state.copyWith(items: [...state.items, item]);
_saveQueueToStorage(); // Persist queue
if (!state.isProcessing) {
// Run in microtask to not block UI
@@ -487,6 +603,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}).toList();
state = state.copyWith(items: [...state.items, ...newItems]);
_saveQueueToStorage(); // Persist queue
if (!state.isProcessing) {
// Run in microtask to not block UI
@@ -508,6 +625,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}).toList();
state = state.copyWith(items: items);
// Persist queue when status changes to completed/failed/skipped (item removed from pending)
if (status == DownloadStatus.completed ||
status == DownloadStatus.failed ||
status == DownloadStatus.skipped) {
_saveQueueToStorage();
}
}
void updateProgress(String id, double progress) {
@@ -526,10 +650,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
).toList();
state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
}
void clearAll() {
state = state.copyWith(items: [], isPaused: false);
_saveQueueToStorage(); // Clear persisted queue
}
/// Pause the download queue
@@ -571,6 +697,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return item;
}).toList();
state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
// Start processing if not already
if (!state.isProcessing) {
@@ -582,6 +709,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
}
/// Embed metadata and cover to a FLAC file after M4A conversion
@@ -656,6 +784,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Track total items at start for notification
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
_completedInSession = 0;
_failedInSession = 0;
// Start foreground service to keep downloads running in background (Android only)
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
final firstItem = state.items.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => state.items.first,
);
try {
await PlatformBridge.startDownloadService(
trackName: firstItem.track.name,
artistName: firstItem.track.artistName,
queueCount: _totalQueuedAtStart,
);
_log.d('Foreground service started');
} catch (e) {
_log.e('Failed to start foreground service: $e');
}
}
// Ensure output directory is initialized before processing
if (state.outputDir.isEmpty) {
@@ -686,6 +834,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_stopProgressPolling();
// Stop foreground service (Android only)
if (Platform.isAndroid) {
try {
await PlatformBridge.stopDownloadService();
_log.d('Foreground service stopped');
} catch (e) {
_log.e('Failed to stop foreground service: $e');
}
}
// Final cleanup after queue finishes
if (_downloadCount > 0) {
_log.d('Final connection cleanup...');
@@ -698,12 +856,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
// Show queue completion notification
final completedCount = state.completedCount;
final failedCount = state.failedCount;
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
if (_totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete(
completedCount: completedCount,
failedCount: failedCount,
completedCount: _completedInSession,
failedCount: _failedInSession,
);
}
@@ -711,8 +868,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(isProcessing: false, currentDownload: null);
}
/// Sequential download processing (original behavior)
/// Sequential download processing (uses multi-progress system with single item)
Future<void> _processQueueSequential() async {
// Start multi-progress polling (works for both sequential and parallel)
_startMultiProgressPolling();
while (true) {
// Check if paused
if (state.isPaused) {
@@ -737,7 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
await _downloadSingleItem(nextItem);
// Clear item progress after download completes
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
}
// Stop polling when queue is done
_stopProgressPolling();
}
/// Parallel download processing with worker pool
@@ -745,7 +911,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
// Start multi-progress polling for concurrent downloads
// Start multi-progress polling (shared with sequential mode)
_startMultiProgressPolling();
while (true) {
@@ -796,6 +962,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (activeDownloads.isNotEmpty) {
await Future.wait(activeDownloads.values);
}
// Stop polling when queue is done
_stopProgressPolling();
}
/// Download a single item (used by both sequential and parallel processing)
@@ -803,11 +972,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
// Only set currentDownload for sequential mode (for progress polling)
if (state.concurrentDownloads == 1) {
state = state.copyWith(currentDownload: item);
_startProgressPolling(item.id);
}
// Set currentDownload for UI reference
state = state.copyWith(currentDownload: item);
updateItemStatus(item.id, DownloadStatus.downloading);
@@ -863,11 +1029,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
convertLyricsToRomaji: settings.convertLyricsToRomaji,
);
}
// Stop progress polling for this item (sequential mode only)
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
_log.d('Result: $result');
@@ -904,12 +1065,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: 1.0,
filePath: filePath,
);
// Increment completed counter
_completedInSession++;
// Show completion notification for this track
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: state.completedCount,
completedCount: _completedInSession,
totalCount: _totalQueuedAtStart,
);
@@ -947,6 +1111,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
DownloadStatus.failed,
error: errorMsg,
);
_failedInSession++;
}
// Increment download counter and cleanup connections periodically
@@ -960,15 +1125,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} catch (e, stackTrace) {
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
_log.e('Exception: $e', e, stackTrace);
updateItemStatus(
item.id,
DownloadStatus.failed,
error: e.toString(),
);
_failedInSession++;
}
}
}
+70 -87
View File
@@ -6,54 +6,59 @@ class TrackState {
final List<Track> tracks;
final bool isLoading;
final String? error;
final String? albumId;
final String? albumName;
final String? playlistName;
final String? artistId;
final String? artistName;
final String? coverUrl;
final List<ArtistAlbum>? artistAlbums; // For artist page
final TrackState? previousState; // For back navigation
final List<SearchArtist>? searchArtists; // For search results
final bool hasSearchText; // For back button handling
const TrackState({
this.tracks = const [],
this.isLoading = false,
this.error,
this.albumId,
this.albumName,
this.playlistName,
this.artistId,
this.artistName,
this.coverUrl,
this.artistAlbums,
this.previousState,
this.searchArtists,
this.hasSearchText = false,
});
bool get canGoBack => previousState != null;
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
TrackState copyWith({
List<Track>? tracks,
bool? isLoading,
String? error,
String? albumId,
String? albumName,
String? playlistName,
String? artistId,
String? artistName,
String? coverUrl,
List<ArtistAlbum>? artistAlbums,
TrackState? previousState,
bool clearPreviousState = false,
List<SearchArtist>? searchArtists,
bool? hasSearchText,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
isLoading: isLoading ?? this.isLoading,
error: error,
albumId: albumId ?? this.albumId,
albumName: albumName ?? this.albumName,
playlistName: playlistName ?? this.playlistName,
artistId: artistId ?? this.artistId,
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
artistAlbums: artistAlbums ?? this.artistAlbums,
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
searchArtists: searchArtists ?? this.searchArtists,
hasSearchText: hasSearchText ?? this.hasSearchText,
);
}
@@ -80,31 +85,49 @@ class ArtistAlbum {
});
}
/// Represents an artist in search results
class SearchArtist {
final String id;
final String name;
final String? imageUrl;
final int followers;
final int popularity;
const SearchArtist({
required this.id,
required this.name,
this.imageUrl,
required this.followers,
required this.popularity,
});
}
class TrackNotifier extends Notifier<TrackState> {
/// Request ID to track and cancel outdated requests
int _currentRequestId = 0;
@override
TrackState build() {
return const TrackState();
}
Future<void> fetchFromUrl(String url) async {
// Save current state for back navigation (only if we have content or it's empty)
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
albumName: state.albumName,
playlistName: state.playlistName,
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState,
) : const TrackState(); // Empty state for back to home
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
state = TrackState(isLoading: true, previousState: savedState);
Future<void> fetchFromUrl(String url) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
state = const TrackState(isLoading: true);
try {
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
final type = parsed['type'] as String;
final metadata = await PlatformBridge.getSpotifyMetadata(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
@@ -113,7 +136,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
previousState: savedState,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
@@ -122,9 +144,9 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
previousState: savedState,
);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -136,7 +158,6 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
previousState: savedState,
);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
@@ -145,42 +166,42 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
tracks: [], // No tracks for artist view
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
previousState: savedState,
);
}
} catch (e) {
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString());
}
}
Future<void> search(String query) async {
// Save current state for back navigation
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
albumName: state.albumName,
playlistName: state.playlistName,
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState,
) : const TrackState();
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
state = TrackState(isLoading: true, previousState: savedState);
state = const TrackState(isLoading: true);
try {
final results = await PlatformBridge.searchSpotify(query, limit: 20);
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
if (!_isRequestValid(requestId)) return; // Request cancelled
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
searchArtists: artists,
isLoading: false,
previousState: savedState,
);
} catch (e) {
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString());
}
}
@@ -231,54 +252,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = state.copyWith(hasSearchText: hasText);
}
/// Go back to previous state (if available)
bool goBack() {
if (state.previousState != null) {
state = state.previousState!;
return true;
}
return false;
}
/// Fetch album from artist view - saves current artist state for back navigation
Future<void> fetchAlbumFromArtist(String albumId) async {
// Save current artist state before fetching album
final savedState = TrackState(
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState, // Keep the chain
);
state = TrackState(
isLoading: true,
previousState: savedState,
);
try {
final url = 'https://open.spotify.com/album/$albumId';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
previousState: savedState,
);
} catch (e) {
state = TrackState(
isLoading: false,
error: e.toString(),
previousState: savedState,
);
}
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
id: data['spotify_id'] as String? ?? '',
@@ -322,6 +295,16 @@ class TrackNotifier extends Notifier<TrackState> {
artists: data['artists'] as String? ?? '',
);
}
SearchArtist _parseSearchArtist(Map<String, dynamic> data) {
return SearchArtist(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
imageUrl: data['images'] as String?,
followers: data['followers'] as int? ?? 0,
popularity: data['popularity'] as int? ?? 0,
);
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+591
View File
@@ -0,0 +1,591 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
/// Simple in-memory cache for album tracks
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static List<Track>? get(String albumId) {
final entry = _cache[albumId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(albumId);
return null;
}
return entry.tracks;
}
static void set(String albumId, List<Track> tracks) {
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
}
}
class _CacheEntry {
final List<Track> tracks;
final DateTime expiresAt;
_CacheEntry(this.tracks, this.expiresAt);
}
/// Album detail screen with Material Expressive 3 design
class AlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null
const AlbumScreen({
super.key,
required this.albumId,
required this.albumName,
this.coverUrl,
this.tracks,
});
@override
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
}
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
// Priority: widget.tracks > cache > fetch
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
if (_tracks == null) {
_fetchTracks();
}
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
final url = 'https://open.spotify.com/album/${widget.albumId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Store in cache
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final tracks = _tracks ?? [];
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoading)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
)),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
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],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
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: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
),
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),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: 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: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 8),
if (tracks.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: tracks.length,
),
);
}
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
void _downloadAll(BuildContext context) {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
);
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
onTap: onTap,
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
final String? coverUrl;
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
@override
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
}
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
child: Column(
children: [
const SizedBox(height: 8),
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.coverUrl != null
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
],
],
);
},
),
),
if (_isOverflowing || _expanded)
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
],
),
),
],
),
),
);
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _AlbumTrackItem extends ConsumerWidget {
final Track track;
final VoidCallback onDownload;
const _AlbumTrackItem({required this.track, required this.onDownload});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
if (isQueued) return;
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
],
),
);
} else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
} else {
return GestureDetector(
onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
);
}
}
}
+321
View File
@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
/// Simple in-memory cache for artist discography
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static List<ArtistAlbum>? get(String artistId) {
final entry = _cache[artistId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(artistId);
return null;
}
return entry.albums;
}
static void set(String artistId, List<ArtistAlbum> albums) {
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
}
}
class _CacheEntry {
final List<ArtistAlbum> albums;
final DateTime expiresAt;
_CacheEntry(this.albums, this.expiresAt);
}
/// Artist screen with Material Expressive 3 design - shows discography
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
final String? coverUrl;
final List<ArtistAlbum>? albums; // Optional - will fetch if null
const ArtistScreen({
super.key,
required this.artistId,
required this.artistName,
this.coverUrl,
this.albums,
});
@override
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
}
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
String? _error;
@override
void initState() {
super.initState();
// Priority: widget.albums > cache > fetch
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
if (_albums == null) {
_fetchDiscography();
}
}
Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true);
try {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Store in cache
_ArtistCache.set(widget.artistId, albums);
if (mounted) {
setState(() {
_albums = albums;
_isLoadingDiscography = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoadingDiscography = false;
});
}
}
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final albums = _albums ?? [];
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
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],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
),
child: ClipOval(
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
),
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),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: 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: [
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
if (_albums != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
),
),
),
),
);
}
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Icon(Icons.album, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
],
),
),
SizedBox(
height: 210,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
},
),
),
],
);
}
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
return GestureDetector(
onTap: () => _navigateToAlbum(album),
child: Container(
width: 140,
margin: const EdgeInsets.symmetric(horizontal: 6),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverUrl != null
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
),
const SizedBox(height: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text(
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
),
);
}
void _navigateToAlbum(ArtistAlbum album) {
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
ref.read(settingsProvider.notifier).setHasSearchedBefore();
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
// tracks: null - will be fetched in AlbumScreen
),
));
}
}
+563 -302
View File
File diff suppressed because it is too large Load Diff
+66 -49
View File
@@ -27,6 +27,7 @@ class _MainShellState extends ConsumerState<MainShell> {
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress; // For double-tap to exit
@override
void initState() {
@@ -47,11 +48,17 @@ class _MainShellState extends ConsumerState<MainShell> {
_handleSharedUrl(pendingUrl);
}
// Listen for future shared URLs
_shareSubscription = ShareIntentService().sharedUrlStream.listen((url) {
_log.d('Received shared URL from stream: $url');
_handleSharedUrl(url);
});
// Listen for future shared URLs with error handling
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
(url) {
_log.d('Received shared URL from stream: $url');
_handleSharedUrl(url);
},
onError: (error) {
_log.e('Share stream error: $error');
},
cancelOnError: false,
);
}
void _handleSharedUrl(String url) {
@@ -114,59 +121,66 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
Future<bool> _showExitDialog() async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit App'),
content: const Text('Are you sure you want to exit?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
) ?? false;
/// Handle back press with double-tap to exit
void _handleBackPress() {
final trackState = ref.read(trackProvider);
// If on Home tab and has text in search bar or has content (but not loading), clear it
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Home tab, go to Home tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Double-tap to exit
final now = DateTime.now();
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
SystemNavigator.pop();
} else {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Press back again to exit'),
duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
}
@override
Widget build(BuildContext context) {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider);
// Determine if we can pop (for predictive back animation)
// canPop is true when we're at root with no content - enables predictive back gesture
final canPop = _currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading;
return PopScope(
canPop: false,
canPop: canPop,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
// If on Search tab and can go back in track history, go back
if (_currentIndex == 0 && trackState.canGoBack) {
ref.read(trackProvider.notifier).goBack();
if (didPop) {
// System handled the pop - this means predictive back completed
// We need to handle double-tap to exit here
return;
}
// If on Search tab and has text in search bar or has content, clear it
if (_currentIndex == 0 && (trackState.hasSearchText || trackState.hasContent || trackState.isLoading)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Search tab, go to Search tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// Already at root, show exit dialog
final shouldPop = await _showExitDialog();
if (shouldPop && context.mounted) {
SystemNavigator.pop();
}
// Handle back press manually when canPop is false
_handleBackPress();
},
child: Scaffold(
body: PageView(
@@ -183,11 +197,14 @@ class _MainShellState extends ConsumerState<MainShell> {
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 200),
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
destinations: [
const NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Badge(
+453
View File
@@ -0,0 +1,453 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
const PlaylistScreen({
super.key,
required this.playlistName,
this.coverUrl,
required this.tracks,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, ref, colorScheme),
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, ref, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (coverUrl != null)
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
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],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
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: coverUrl != null
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
),
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),
),
);
}
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: 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: [
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, ref, track),
),
);
},
childCount: tracks.length,
),
);
}
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
void _downloadAll(BuildContext context, WidgetRef ref) {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: playlistName);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
);
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
onTap: onTap,
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
final String? coverUrl;
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
@override
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
}
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
child: Column(
children: [
const SizedBox(height: 8),
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.coverUrl != null
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
],
],
);
},
),
),
if (_isOverflowing || _expanded)
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
],
),
),
],
),
),
);
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _PlaylistTrackItem extends ConsumerWidget {
final Track track;
final VoidCallback onDownload;
const _PlaylistTrackItem({required this.track, required this.onDownload});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
if (isQueued) return;
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
],
),
);
} else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
} else {
return GestureDetector(
onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
);
}
}
}
+12
View File
@@ -144,6 +144,18 @@ class QueueScreen extends ConsumerWidget {
color: colorScheme.primary,
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 24,
height: 24,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
],
),
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
+90 -27
View File
@@ -16,20 +16,41 @@ class QueueTab extends ConsumerStatefulWidget {
class _QueueTabState extends ConsumerState<QueueTab> {
final Map<String, bool> _fileExistsCache = {};
final Set<String> _pendingChecks = {}; // Track pending async checks
static const int _maxCacheSize = 500; // Limit cache size to prevent memory leak
/// Check if file exists - returns true optimistically while checking
/// This prevents the "red flash" on app start
bool _checkFileExists(String? filePath) {
if (filePath == null) return false;
// If already cached, return cached value
if (_fileExistsCache.containsKey(filePath)) {
return _fileExistsCache[filePath]!;
}
// If check is pending, return true optimistically (assume file exists)
if (_pendingChecks.contains(filePath)) {
return true;
}
// Limit cache size - remove oldest entry if full
if (_fileExistsCache.length >= _maxCacheSize) {
_fileExistsCache.remove(_fileExistsCache.keys.first);
}
// Mark as pending and start async check
_pendingChecks.add(filePath);
Future.microtask(() async {
final exists = await File(filePath).exists();
_pendingChecks.remove(filePath);
if (mounted && _fileExistsCache[filePath] != exists) {
setState(() => _fileExistsCache[filePath] = exists);
}
});
_fileExistsCache[filePath] = false;
return false;
// Return true optimistically while checking
return true;
}
Future<void> _openFile(String filePath) async {
@@ -69,8 +90,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@override
Widget build(BuildContext context) {
final queueState = ref.watch(downloadQueueProvider);
final historyState = ref.watch(downloadHistoryProvider);
// Use select() to only rebuild when specific fields change
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing));
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount));
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
final colorScheme = Theme.of(context).colorScheme;
@@ -100,7 +126,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
// Pause/Resume controls - only show when multiple items or paused
if ((queueState.isProcessing || queueState.queuedCount > 0) && (queueState.items.length > 1 || queueState.isPaused))
if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -113,14 +139,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: queueState.isPaused
color: isPaused
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
queueState.isPaused ? Icons.pause : Icons.downloading,
color: queueState.isPaused
isPaused ? Icons.pause : Icons.downloading,
color: isPaused
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer,
),
@@ -129,9 +155,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
// Status text - simplified
Expanded(
child: Text(
queueState.isPaused
isPaused
? 'Paused'
: '${queueState.completedCount}/${queueState.items.length}',
: '$completedCount/${queueItems.length}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -140,7 +166,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
// Pause/Resume button
FilledButton.tonal(
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
child: Text(queueState.isPaused ? 'Resume' : 'Pause'),
child: Text(isPaused ? 'Resume' : 'Pause'),
),
],
),
@@ -150,34 +176,40 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
// Queue header
if (queueState.items.isNotEmpty)
if (queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Downloading (${queueState.items.length})',
child: Text('Downloading (${queueItems.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
),
// Queue list
if (queueState.items.isNotEmpty)
// Queue list with keys for efficient updates
if (queueItems.isNotEmpty)
SliverList(delegate: SliverChildBuilderDelegate(
(context, index) => _buildQueueItem(context, queueState.items[index], colorScheme),
childCount: queueState.items.length,
(context, index) {
final item = queueItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildQueueItem(context, item, colorScheme),
);
},
childCount: queueItems.length,
)),
// History section header - show count only
if (historyState.items.isNotEmpty && queueState.items.isEmpty)
if (historyItems.isNotEmpty && queueItems.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('${historyState.items.length} ${historyState.items.length == 1 ? 'track' : 'tracks'}',
child: Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
),
),
// History section header when queue has items (show "Downloaded" label)
if (historyState.items.isNotEmpty && queueState.items.isNotEmpty)
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -186,8 +218,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// History - Grid or List based on setting
if (historyState.items.isNotEmpty)
// History - Grid or List based on setting (with keys)
if (historyItems.isNotEmpty)
historyViewMode == 'grid'
? SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -199,18 +231,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildHistoryGridItem(context, historyState.items[index], colorScheme),
childCount: historyState.items.length,
(context, index) {
final item = historyItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildHistoryGridItem(context, item, colorScheme),
);
},
childCount: historyItems.length,
),
),
)
: SliverList(delegate: SliverChildBuilderDelegate(
(context, index) => _buildHistoryItem(context, historyState.items[index], colorScheme),
childCount: historyState.items.length,
(context, index) {
final item = historyItems[index];
return KeyedSubtree(
key: ValueKey(item.id),
child: _buildHistoryItem(context, item, colorScheme),
);
},
childCount: historyItems.length,
)),
// Empty state when both queue and history are empty
if (queueState.items.isEmpty && historyState.items.isEmpty)
if (queueItems.isEmpty && historyItems.isEmpty)
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
else
const SliverToBoxAdapter(child: SizedBox(height: 16)),
@@ -380,6 +424,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
],
);
case DownloadStatus.finalizing:
// Finalizing: Show spinner with edit icon (embedding metadata)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
),
],
);
case DownloadStatus.completed:
// Completed: Show play button and check icon
final fileExists = _checkFileExists(item.filePath);
+285 -88
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget {
const AboutPage({super.key});
@@ -21,7 +23,10 @@ class AboutPage extends StatelessWidget {
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
@@ -35,10 +40,13 @@ class AboutPage extends StatelessWidget {
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
// When collapsed (expandRatio=0): left=56 to align with back button
// When expanded (expandRatio=1): left=24 for normal padding
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('About',
child: Text(
'About',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
@@ -52,73 +60,90 @@ class AboutPage extends StatelessWidget {
),
),
// App info card
// App header card with logo and description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
),
),
const SizedBox(width: 16),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
),
]),
]),
),
),
child: _AppHeaderCard(),
),
),
// GitHub section
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
SliverList(delegate: SliverChildListDelegate([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
trailing: const Icon(Icons.open_in_new, size: 20),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
title: Text('Original ${AppInfo.appName}'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
trailing: const Icon(Icons.open_in_new, size: 20),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
])),
// Credits section
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
// Contributors section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Contributors'),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(children: [
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
const SizedBox(height: 12),
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
]),
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: 'Mobile version developer',
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: 'Creator of the original SpotiFLAC',
githubUsername: AppInfo.originalAuthor,
showDivider: false,
),
],
),
),
// Links section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Links'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.phone_android,
title: 'Mobile source code',
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
SettingsItem(
icon: Icons.computer,
title: 'PC source code',
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
SettingsItem(
icon: Icons.bug_report_outlined,
title: 'Report an issue',
subtitle: 'Report any problems you encounter',
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
SettingsItem(
icon: Icons.lightbulb_outline,
title: 'Feature request',
subtitle: 'Suggest new features for the app',
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
),
),
// App info section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.info_outline,
title: 'Version',
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
),
),
@@ -126,42 +151,214 @@ class AboutPage extends StatelessWidget {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(child: Text(AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
);
}
static Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
// Use inAppBrowserView for reliable URL opening with app chooser
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
class _AppHeaderCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest;
return Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(24),
child: Column(
children: [
// App logo
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.2),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Icon(
Icons.music_note,
size: 48,
color: colorScheme.onPrimaryContainer,
),
),
),
),
const SizedBox(height: 16),
// App name
Text(
AppInfo.appName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
// Version badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 16),
// Description
Text(
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
);
}
class _ContributorItem extends StatelessWidget {
final String name;
final String description;
final String githubUsername;
final bool showDivider;
const _ContributorItem({
required this.name,
required this.description,
required this.githubUsername,
this.showDivider = false,
});
class _CreditRow extends StatelessWidget {
final String label;
final String value;
const _CreditRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
]);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => _launchGitHub(githubUsername),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// GitHub Avatar
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png',
width: 40,
height: 40,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 40,
height: 40,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
color: colorScheme.onSurfaceVariant,
size: 20,
),
),
errorWidget: (context, url, error) => Container(
width: 40,
height: 40,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
color: colorScheme.onSurfaceVariant,
size: 20,
),
),
),
),
const SizedBox(width: 16),
// Name and description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// GitHub icon
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 76,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
Future<void> _launchGitHub(String username) async {
final uri = Uri.parse('https://github.com/$username');
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
@@ -40,7 +40,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('Appearance',
style: TextStyle(
@@ -144,27 +144,38 @@ class _ThemeModeChip extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need to be darker than the card background
// Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
),
),
),
),
@@ -251,26 +262,36 @@ class _ViewModeChip extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
),
),
),
),
@@ -39,7 +39,7 @@ class DownloadSettingsPage extends ConsumerWidget {
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('Download',
style: TextStyle(
@@ -39,7 +39,7 @@ class OptionsSettingsPage extends ConsumerWidget {
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('Options',
style: TextStyle(
+16 -6
View File
@@ -14,9 +14,18 @@ class ApkDownloader {
required String version,
ProgressCallback? onProgress,
}) async {
// Validate URL for security
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != 'https') {
_log.e('Refusing to download from invalid or non-HTTPS URL');
return null;
}
final client = http.Client();
IOSink? sink;
try {
final client = http.Client();
final request = http.Request('GET', Uri.parse(url));
final request = http.Request('GET', uri);
final response = await client.send(request);
if (response.statusCode != 200) {
@@ -41,7 +50,7 @@ class ApkDownloader {
await file.delete();
}
final sink = file.openWrite();
sink = file.openWrite();
int received = 0;
await for (final chunk in response.stream) {
@@ -50,14 +59,15 @@ class ApkDownloader {
onProgress?.call(received, contentLength);
}
await sink.close();
client.close();
await sink.flush();
_log.i('Downloaded to: $filePath');
return filePath;
} catch (e) {
_log.e('Error: $e');
return null;
} finally {
await sink?.close();
client.close();
}
}
+43
View File
@@ -98,6 +98,49 @@ class NotificationService {
);
}
Future<void> showDownloadFinalizing({
required String trackName,
required String artistName,
}) async {
if (!_isInitialized) await initialize();
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: 100,
indeterminate: false,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
downloadProgressId,
'Finalizing $trackName',
'$artistName • Embedding metadata...',
details,
);
}
Future<void> showDownloadComplete({
required String trackName,
required String artistName,
+51
View File
@@ -26,6 +26,16 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Search Spotify for tracks and artists
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
final result = await _channel.invokeMethod('searchSpotifyAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Check track availability on streaming services
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
final result = await _channel.invokeMethod('checkAvailability', {
@@ -234,4 +244,45 @@ class PlatformBridge {
static Future<void> cleanupConnections() async {
await _channel.invokeMethod('cleanupConnections');
}
/// Start foreground download service to keep downloads running in background
static Future<void> startDownloadService({
String trackName = '',
String artistName = '',
int queueCount = 0,
}) async {
await _channel.invokeMethod('startDownloadService', {
'track_name': trackName,
'artist_name': artistName,
'queue_count': queueCount,
});
}
/// Stop foreground download service
static Future<void> stopDownloadService() async {
await _channel.invokeMethod('stopDownloadService');
}
/// Update download service notification progress
static Future<void> updateDownloadServiceProgress({
required String trackName,
required String artistName,
required int progress,
required int total,
required int queueCount,
}) async {
await _channel.invokeMethod('updateDownloadServiceProgress', {
'track_name': trackName,
'artist_name': artistName,
'progress': progress,
'total': total,
'queue_count': queueCount,
});
}
/// Check if download service is running
static Future<bool> isDownloadServiceRunning() async {
final result = await _channel.invokeMethod('isDownloadServiceRunning');
return result as bool;
}
}
+6
View File
@@ -92,6 +92,12 @@ class UpdateChecker {
final name = (asset['name'] as String? ?? '').toLowerCase();
if (name.endsWith('.apk')) {
final downloadUrl = asset['browser_download_url'] as String?;
// Only accept HTTPS URLs for security
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
continue;
}
if (name.contains('arm64') || name.contains('v8a')) {
arm64Url = downloadUrl;
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
-2
View File
@@ -27,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
// Use dynamic colors from wallpaper (Android 12+)
lightScheme = lightDynamic;
darkScheme = darkDynamic;
debugPrint('Using dynamic color from wallpaper');
} else {
// Fallback to seed color
final seedColor = themeSettings.seedColor;
@@ -39,7 +38,6 @@ class DynamicColorWrapper extends ConsumerWidget {
seedColor: seedColor,
brightness: Brightness.dark,
);
debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}');
}
// Build themes
+2 -1
View File
@@ -20,9 +20,10 @@ class SettingsGroup extends StatelessWidget {
// Use a more contrasting color for cards
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
// So we add a slight white overlay to make it more visible
// In light mode with dynamic color, we add a slight black overlay for the same reason
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest;
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
return Container(
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+221 -118
View File
@@ -69,6 +69,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
);
if (filePath != null) {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadComplete(
version: widget.updateInfo.version,
);
@@ -80,6 +83,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
// Open APK for installation
await ApkDownloader.installApk(filePath);
} else {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadFailed();
if (mounted) {
@@ -98,129 +104,202 @@ class _UpdateDialogState extends State<UpdateDialog> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
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,
return Dialog(
backgroundColor: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Version info
// Header with icon
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Icon(Icons.system_update_rounded, color: colorScheme.onPrimaryContainer, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
),
],
),
const SizedBox(height: 20),
// Version badge
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
color: isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'v${AppInfo.version}',
style: TextStyle(color: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 8),
Text(
'v${widget.updateInfo.version}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
const SizedBox(width: 12),
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 12),
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
],
),
),
const SizedBox(height: 16),
const SizedBox(height: 20),
// Changelog header
Text(
'What\'s New:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Changelog content (scrollable) - hide when downloading
if (!_isDownloading)
Flexible(
child: Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Text(
_formatChangelog(widget.updateInfo.changelog),
style: Theme.of(context).textTheme.bodySmall,
// Download progress (when downloading)
if (_isDownloading) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
),
const SizedBox(width: 12),
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _progress,
minHeight: 6,
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_statusText, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
Text('${(_progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600)),
],
),
],
),
),
] else ...[
// Changelog section
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 180),
decoration: BoxDecoration(
color: isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
borderRadius: BorderRadius.circular(16),
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
_formatChangelog(widget.updateInfo.changelog),
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5),
),
),
),
// Download progress
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: _progress),
const SizedBox(height: 8),
Text(
_statusText,
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: 24),
// Action buttons
if (_isDownloading)
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Cancel'),
),
)
else
Column(
children: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _downloadAndInstall,
icon: const Icon(Icons.download_rounded, size: 20),
label: const Text('Download & Install'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
widget.onDisableUpdates();
Navigator.pop(context);
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () {
widget.onDismiss();
Navigator.pop(context);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Later'),
),
),
],
),
],
),
],
),
),
actions: _isDownloading
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
]
: [
// Don't remind again button
TextButton(
onPressed: () {
widget.onDisableUpdates();
Navigator.pop(context);
},
child: Text(
'Don\'t remind',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
// Later button
TextButton(
onPressed: () {
widget.onDismiss();
Navigator.pop(context);
},
child: const Text('Later'),
),
// Download button
FilledButton(
onPressed: _downloadAndInstall,
child: const Text('Install'),
),
],
);
}
/// Format changelog - clean up markdown and extract relevant content
String _formatChangelog(String changelog) {
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
var content = changelog;
// Find content after "What's New" header
@@ -238,19 +317,18 @@ class _UpdateDialogState extends State<UpdateDialog> {
// Process line by line for better formatting
final lines = content.split('\n');
final formattedLines = <String>[];
String? currentSection;
for (var line in lines) {
line = line.trim();
if (line.isEmpty) continue;
// Check if it's a section header (### Added, ### Fixed, etc.)
// Check if it's a section header
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
if (sectionMatch != null) {
currentSection = sectionMatch.group(1)?.trim();
if (currentSection != null && currentSection.isNotEmpty) {
final section = sectionMatch.group(1)?.trim();
if (section != null && section.isNotEmpty) {
if (formattedLines.isNotEmpty) formattedLines.add('');
formattedLines.add('$currentSection:');
formattedLines.add(section);
}
continue;
}
@@ -259,36 +337,23 @@ class _UpdateDialogState extends State<UpdateDialog> {
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
if (listMatch != null) {
var itemText = listMatch.group(1) ?? '';
// Remove bold markdown
itemText = itemText.replaceAllMapped(
RegExp(r'\*\*([^*]+)\*\*'),
(m) => m.group(1) ?? ''
);
// Remove code markdown
itemText = itemText.replaceAllMapped(
RegExp(r'`([^`]+)`'),
(m) => m.group(1) ?? ''
);
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
formattedLines.add('$itemText');
continue;
}
// Check if it's a sub-item (indented list)
// Check if it's a sub-item
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
if (subListMatch != null) {
var itemText = subListMatch.group(1) ?? '';
itemText = itemText.replaceAllMapped(
RegExp(r'\*\*([^*]+)\*\*'),
(m) => m.group(1) ?? ''
);
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
formattedLines.add(' - $itemText');
continue;
}
}
var formatted = formattedLines.join('\n').trim();
// Limit length
if (formatted.length > 2000) {
formatted = '${formatted.substring(0, 2000)}...';
}
@@ -297,6 +362,44 @@ class _UpdateDialogState extends State<UpdateDialog> {
}
}
class _VersionChip extends StatelessWidget {
final String version;
final String label;
final ColorScheme colorScheme;
final bool isNew;
const _VersionChip({
required this.version,
required this.label,
required this.colorScheme,
this.isNew = false,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'v$version',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: isNew ? FontWeight.bold : FontWeight.w500,
),
),
),
],
);
}
}
/// Show update dialog
Future<void> showUpdateDialog(
BuildContext context, {
+1 -1
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.6.0+25
version: 2.0.1+31
environment:
sdk: ^3.10.0
-38
View File
@@ -1,38 +0,0 @@
# 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