Compare commits

..

6 Commits

Author SHA1 Message Date
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
zarzet bd9b527161 release: v1.6.0 - Live search, quality picker, dependency updates 2026-01-02 17:13:22 +07:00
zarzet 39bcc2c547 feat: live search with back navigation and animated transitions 2026-01-02 16:43:59 +07:00
zarzet 973c2e3b41 v1.5.6: UI improvements, logger migration, and bug fixes
- Fix update checker for versions with suffix (hotfix/beta/rc)
- Add collapsing header to Search tab for consistent UI
- Redesign Settings with Android-style grouped cards
- Increase app bar title size (28px) and height (130px)
- Replace all print() with structured logging (logger package)
- Fix lint warnings (curly braces, unnecessary underscores)
2026-01-02 15:16:50 +07:00
zarzet 62805720da Add auto-tag workflow on version change 2026-01-02 06:52:28 +07:00
37 changed files with 1660 additions and 666 deletions
+77
View File
@@ -0,0 +1,77 @@
name: Auto Tag on Version Change
on:
push:
branches:
- main
paths:
- 'pubspec.yaml'
jobs:
check-version:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
- name: Get current version
id: current
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Current version: $VERSION"
- name: Get previous version
id: previous
run: |
git checkout HEAD~1 -- pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > pubspec.yaml.old
if [ -f pubspec.yaml.old ]; then
VERSION=$(grep '^version:' pubspec.yaml.old | sed 's/version: //' | cut -d'+' -f1)
else
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
fi
git checkout HEAD -- pubspec.yaml
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Previous version: $VERSION"
- name: Check if version changed
id: check
run: |
CURRENT="${{ steps.current.outputs.version }}"
PREVIOUS="${{ steps.previous.outputs.version }}"
if [ "$CURRENT" != "$PREVIOUS" ]; then
echo "Version changed from $PREVIOUS to $CURRENT"
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "Version unchanged: $CURRENT"
echo "changed=false" >> $GITHUB_OUTPUT
fi
- name: Check if tag exists
id: tag_exists
if: steps.check.outputs.changed == 'true'
run: |
TAG="v${{ steps.current.outputs.version }}"
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
echo "Tag $TAG already exists"
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "Tag $TAG does not exist"
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Create and push tag
if: steps.check.outputs.changed == 'true' && steps.tag_exists.outputs.exists == 'false'
run: |
TAG="v${{ steps.current.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
echo "Created and pushed tag: $TAG"
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## [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
- **Manual Quality Selection**: New option to choose audio quality before each download
- Toggle "Ask Before Download" in Download Settings
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
- Works for both single track and batch downloads
- **Live Search**: Search results appear as you type with 400ms debounce
- Animated search bar moves from center to top when typing
- Keyboard stays open during transition
- Back button navigates through search history (album → artist → idle)
- Clear button to reset search
- URLs still require manual submit
- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
### Fixed
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
- Users on hotfix versions now properly receive update notifications
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
### Changed
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
- Items in same group are connected with rounded card container
- Section headers outside cards for clear visual hierarchy
- Better contrast with white overlay for dark mode dynamic colors
- **Larger Tab Titles**: Increased app bar title size (28px) and height (130px) for better visibility
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
### Improved
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
- **Dependencies Updated**:
- `share_plus`: 10.1.4 → 12.0.1
- `flutter_local_notifications`: 18.0.1 → 19.0.0
- `build_runner`: 2.4.15 → 2.10.4
## [1.5.5] - 2026-01-02
### Added
+2
View File
@@ -39,6 +39,8 @@ 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!
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
+2 -2
View File
@@ -12,6 +12,7 @@
<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"
@@ -23,8 +24,7 @@
<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)
@@ -160,6 +167,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")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '1.5.5';
static const String buildNumber = '22';
static const String version = '1.6.1';
static const String buildNumber = '26';
static const String fullVersion = '$version+$buildNumber';
static const String appName = 'SpotiFLAC';
+4
View File
@@ -22,6 +22,7 @@ class DownloadItem {
final String? filePath;
final String? error;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
const DownloadItem({
required this.id,
@@ -32,6 +33,7 @@ class DownloadItem {
this.filePath,
this.error,
required this.createdAt,
this.qualityOverride,
});
DownloadItem copyWith({
@@ -43,6 +45,7 @@ class DownloadItem {
String? filePath,
String? error,
DateTime? createdAt,
String? qualityOverride,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -53,6 +56,7 @@ class DownloadItem {
filePath: filePath ?? this.filePath,
error: error ?? this.error,
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
);
}
+2
View File
@@ -17,6 +17,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'filePath': instance.filePath,
'error': instance.error,
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
};
const _$DownloadStatusEnumMap = {
+4
View File
@@ -18,6 +18,7 @@ class AppSettings {
final String folderOrganization; // none, artist, album, artist_album
final bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji
final String historyViewMode; // list, grid
final bool askQualityBeforeDownload; // Show quality picker before each download
const AppSettings({
this.defaultService = 'tidal',
@@ -34,6 +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
});
AppSettings copyWith({
@@ -51,6 +53,7 @@ class AppSettings {
String? folderOrganization,
bool? convertLyricsToRomaji,
String? historyViewMode,
bool? askQualityBeforeDownload,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -67,6 +70,7 @@ class AppSettings {
folderOrganization: folderOrganization ?? this.folderOrganization,
convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji,
historyViewMode: historyViewMode ?? this.historyViewMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
);
}
+3 -1
View File
@@ -20,7 +20,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'list',
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -39,4 +40,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'folderOrganization': instance.folderOrganization,
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
'historyViewMode': instance.historyViewMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
};
+195 -52
View File
@@ -13,6 +13,10 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
// Download History Item model
class DownloadHistoryItem {
@@ -132,12 +136,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
state = state.copyWith(items: items);
print('[DownloadHistory] Loaded ${items.length} items from storage');
_historyLog.i('Loaded ${items.length} items from storage');
} else {
print('[DownloadHistory] No history found in storage');
_historyLog.d('No history found in storage');
}
} catch (e) {
print('[DownloadHistory] Failed to load history: $e');
_historyLog.e('Failed to load history: $e');
}
}
@@ -146,9 +150,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final prefs = await SharedPreferences.getInstance();
final jsonList = state.items.map((e) => e.toJson()).toList();
await prefs.setString(_storageKey, jsonEncode(jsonList));
print('[DownloadHistory] Saved ${state.items.length} items to storage');
_historyLog.d('Saved ${state.items.length} items to storage');
} catch (e) {
print('[DownloadHistory] Failed to save history: $e');
_historyLog.e('Failed to save history: $e');
}
}
@@ -238,18 +242,90 @@ 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
bool _isLoaded = false;
@override
DownloadQueueState build() {
// Initialize output directory asynchronously
// Initialize output directory and load persisted queue asynchronously
Future.microtask(() async {
await _initOutputDir();
await _loadQueueFromStorage();
});
return const DownloadQueueState();
}
/// 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();
// 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');
// 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);
}
} else {
_log.d('No queue found in storage');
}
} catch (e) {
_log.e('Failed to load queue from storage: $e');
}
}
/// 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');
}
}
void _startProgressPolling(String itemId) {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
@@ -272,12 +348,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: bytesReceived,
total: bytesTotal,
);
// Update foreground service notification (Android)
if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress(
trackName: currentItem.track.name,
artistName: currentItem.track.artistName,
progress: bytesReceived,
total: bytesTotal,
queueCount: state.queuedCount,
).catchError((_) {}); // Ignore errors
}
}
// Log progress
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
print('[DownloadQueue] Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
_log.d('Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
}
} catch (e) {
// Ignore polling errors
@@ -307,7 +394,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Log progress for each item
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
print('[DownloadQueue] Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
}
}
@@ -327,6 +414,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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) {
@@ -424,7 +522,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final dir = Directory(fullPath);
if (!await dir.exists()) {
await dir.create(recursive: true);
print('[DownloadQueue] Created folder: $fullPath');
_log.d('Created folder: $fullPath');
}
return fullPath;
}
@@ -442,7 +540,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
String addToQueue(Track track, String service) {
String addToQueue(Track track, String service, {String? qualityOverride}) {
// Sync settings before adding to queue
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -453,9 +551,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
track: track,
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
);
state = state.copyWith(items: [...state.items, item]);
_saveQueueToStorage(); // Persist queue
if (!state.isProcessing) {
// Run in microtask to not block UI
@@ -465,7 +565,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return id;
}
void addMultipleToQueue(List<Track> tracks, String service) {
void addMultipleToQueue(List<Track> tracks, String service, {String? qualityOverride}) {
// Sync settings before adding to queue
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -477,10 +577,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
track: track,
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
);
}).toList();
state = state.copyWith(items: [...state.items, ...newItems]);
_saveQueueToStorage(); // Persist queue
if (!state.isProcessing) {
// Run in microtask to not block UI
@@ -502,6 +604,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) {
@@ -520,10 +629,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
@@ -531,7 +642,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (state.isProcessing && !state.isPaused) {
state = state.copyWith(isPaused: true);
_notificationService.cancelDownloadNotification();
print('[DownloadQueue] Queue paused');
_log.i('Queue paused');
}
}
@@ -539,7 +650,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void resumeQueue() {
if (state.isPaused) {
state = state.copyWith(isPaused: false);
print('[DownloadQueue] Queue resumed');
_log.i('Queue resumed');
// If there are still queued items, continue processing
if (state.queuedCount > 0 && !state.isProcessing) {
Future.microtask(() => _processQueue());
@@ -565,6 +676,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) {
@@ -576,6 +688,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
@@ -594,14 +707,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
print('[DownloadQueue] Cover downloaded to: $coverPath');
_log.d('Cover downloaded to: $coverPath');
} else {
print('[DownloadQueue] Failed to download cover: HTTP ${response.statusCode}');
_log.w('Failed to download cover: HTTP ${response.statusCode}');
coverPath = null;
}
httpClient.close();
} catch (e) {
print('[DownloadQueue] Failed to download cover: $e');
_log.e('Failed to download cover: $e');
coverPath = null;
}
}
@@ -621,10 +734,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Replace original with temp
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
print('[DownloadQueue] Cover embedded via FFmpeg');
_log.d('Cover embedded via FFmpeg');
} else {
// Try alternative method using metaflac-style embedding
print('[DownloadQueue] FFmpeg cover embed failed, trying alternative...');
_log.w('FFmpeg cover embed failed, trying alternative...');
// Clean up temp file if exists
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
@@ -638,7 +751,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (_) {}
}
} catch (e) {
print('[DownloadQueue] Failed to embed metadata: $e');
_log.e('Failed to embed metadata: $e');
}
}
@@ -646,20 +759,38 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (state.isProcessing) return; // Prevent multiple concurrent processing
state = state.copyWith(isProcessing: true);
print('[DownloadQueue] Starting queue processing...');
_log.i('Starting queue processing...');
// Track total items at start for notification
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
// 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) {
print('[DownloadQueue] Output dir empty, initializing...');
_log.d('Output dir empty, initializing...');
await _initOutputDir();
}
// If still empty, use fallback
if (state.outputDir.isEmpty) {
print('[DownloadQueue] Using fallback directory...');
_log.d('Using fallback directory...');
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
@@ -668,8 +799,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: musicDir.path);
}
print('[DownloadQueue] Output directory: ${state.outputDir}');
print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}');
_log.d('Output directory: ${state.outputDir}');
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
// Use parallel processing if concurrentDownloads > 1
if (state.concurrentDownloads > 1) {
@@ -680,13 +811,23 @@ 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) {
print('[DownloadQueue] Final connection cleanup...');
_log.d('Final connection cleanup...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Final cleanup failed: $e');
_log.e('Final cleanup failed: $e');
}
_downloadCount = 0;
}
@@ -701,7 +842,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
print('[DownloadQueue] Queue processing finished');
_log.i('Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
}
@@ -710,7 +851,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
while (true) {
// Check if paused
if (state.isPaused) {
print('[DownloadQueue] Queue is paused, waiting...');
_log.d('Queue is paused, waiting...');
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
@@ -726,7 +867,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (nextItem.id.isEmpty) {
print('[DownloadQueue] No more items to process');
_log.d('No more items to process');
break;
}
@@ -745,7 +886,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
while (true) {
// Check if paused - don't start new downloads but let active ones finish
if (state.isPaused) {
print('[DownloadQueue] Queue is paused, waiting for active downloads...');
_log.d('Queue is paused, waiting for active downloads...');
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
} else {
@@ -758,7 +899,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
print('[DownloadQueue] No more items to process');
_log.d('No more items to process');
break;
}
@@ -777,7 +918,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
activeDownloads[item.id] = future;
print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
_log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
}
// Wait for at least one download to complete before checking for more
@@ -794,8 +935,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// Download a single item (used by both sequential and parallel processing)
Future<void> _downloadSingleItem(DownloadItem item) async {
print('[DownloadQueue] Processing: ${item.track.name} by ${item.track.artistName}');
print('[DownloadQueue] Cover URL: ${item.track.coverUrl}');
_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) {
@@ -810,12 +951,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final settings = ref.read(settingsProvider);
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
// Use quality override if set, otherwise use default from settings
final quality = item.qualityOverride ?? state.audioQuality;
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
print('[DownloadQueue] Quality: ${state.audioQuality}');
print('[DownloadQueue] Output dir: $outputDir');
_log.d('Using auto-fallback mode');
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
isrc: item.track.isrc ?? '',
spotifyId: item.track.id,
@@ -826,7 +970,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: item.track.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
quality: quality,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
@@ -846,7 +990,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: item.track.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
quality: quality,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
@@ -860,31 +1004,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_stopProgressPolling();
}
print('[DownloadQueue] Result: $result');
_log.d('Result: $result');
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
print('[DownloadQueue] Download success, file: $filePath');
_log.i('Download success, file: $filePath');
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
print('[DownloadQueue] Converting M4A to FLAC...');
_log.d('Converting M4A to FLAC...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
print('[DownloadQueue] Converted to: $flacPath');
_log.d('Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
_log.d('Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
item.track,
);
print('[DownloadQueue] Metadata and cover embedded successfully');
_log.d('Metadata and cover embedded successfully');
} catch (e) {
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
_log.w('Warning: Failed to embed metadata/cover: $e');
}
}
}
@@ -923,7 +1067,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: item.track.discNumber,
duration: item.track.duration,
releaseDate: item.track.releaseDate,
quality: state.audioQuality,
quality: quality,
),
);
@@ -932,7 +1076,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
print('[DownloadQueue] Download failed: $errorMsg');
_log.e('Download failed: $errorMsg');
updateItemStatus(
item.id,
DownloadStatus.failed,
@@ -943,19 +1087,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Increment download counter and cleanup connections periodically
_downloadCount++;
if (_downloadCount % _cleanupInterval == 0) {
print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...');
_log.d('Cleaning up idle connections (after $_downloadCount downloads)...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Connection cleanup failed: $e');
_log.e('Connection cleanup failed: $e');
}
}
} catch (e, stackTrace) {
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
print('[DownloadQueue] Exception: $e');
print('[DownloadQueue] StackTrace: $stackTrace');
_log.e('Exception: $e', e, stackTrace);
updateItemStatus(
item.id,
DownloadStatus.failed,
+5
View File
@@ -98,6 +98,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
}
void setAskQualityBeforeDownload(bool enabled) {
state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+9
View File
@@ -12,6 +12,7 @@ class TrackState {
final String? coverUrl;
final List<ArtistAlbum>? artistAlbums; // For artist page
final TrackState? previousState; // For back navigation
final bool hasSearchText; // For back button handling
const TrackState({
this.tracks = const [],
@@ -23,6 +24,7 @@ class TrackState {
this.coverUrl,
this.artistAlbums,
this.previousState,
this.hasSearchText = false,
});
bool get canGoBack => previousState != null;
@@ -40,6 +42,7 @@ class TrackState {
List<ArtistAlbum>? artistAlbums,
TrackState? previousState,
bool clearPreviousState = false,
bool? hasSearchText,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
@@ -51,6 +54,7 @@ class TrackState {
coverUrl: coverUrl ?? this.coverUrl,
artistAlbums: artistAlbums ?? this.artistAlbums,
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
hasSearchText: hasSearchText ?? this.hasSearchText,
);
}
}
@@ -222,6 +226,11 @@ class TrackNotifier extends Notifier<TrackState> {
state = const TrackState();
}
/// Set search text state for back button handling
void setSearchText(bool hasText) {
state = state.copyWith(hasSearchText: hasText);
}
/// Go back to previous state (if available)
bool goBack() {
if (state.previousState != null) {
+1 -1
View File
@@ -219,7 +219,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
+333 -171
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -15,21 +16,102 @@ class HomeTab extends ConsumerStatefulWidget {
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController();
Timer? _debounce;
bool _isTyping = false;
final FocusNode _searchFocusNode = FocusNode();
@override
bool get wantKeepAlive => true;
@override
void dispose() { _urlController.dispose(); super.dispose(); }
void initState() {
super.initState();
_urlController.addListener(_onSearchChanged);
}
@override
void dispose() {
_debounce?.cancel();
_urlController.removeListener(_onSearchChanged);
_urlController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
/// Called when trackState changes - used to sync search bar with state
void _onTrackStateChanged(TrackState? previous, TrackState next) {
// If state was cleared (no content, no search text, not loading), clear the search bar
if (previous != null &&
!next.hasContent &&
!next.hasSearchText &&
!next.isLoading &&
_urlController.text.isNotEmpty) {
_urlController.clear();
setState(() => _isTyping = false);
}
} void _onSearchChanged() {
final text = _urlController.text.trim();
final wasFocused = _searchFocusNode.hasFocus;
// Update search text state for MainShell back button handling
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
// Update typing state immediately for UI transition
if (text.isNotEmpty && !_isTyping) {
setState(() => _isTyping = true);
} else if (text.isEmpty && _isTyping) {
setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear();
return;
}
// Re-request focus after rebuild if it was focused
if (wasFocused) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_searchFocusNode.requestFocus();
}
});
}
// Don't live search for URLs - wait for submit
if (text.startsWith('http') || text.startsWith('spotify:')) {
_debounce?.cancel();
return;
}
// Debounce search queries
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
if (text.length >= 2) {
_performSearch(text);
}
});
}
Future<void> _performSearch(String query) async {
await ref.read(trackProvider.notifier).search(query);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) _urlController.text = data!.text!;
if (data?.text != null) {
_urlController.text = data!.text!;
// For URLs, trigger fetch immediately after paste
final text = data.text!.trim();
if (text.startsWith('http') || text.startsWith('spotify:')) {
_fetchMetadata();
}
}
}
Future<void> _clearAndRefresh() async {
_debounce?.cancel();
_urlController.clear();
_searchFocusNode.unfocus();
setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear();
await Future.delayed(const Duration(milliseconds: 300));
}
Future<void> _fetchMetadata() async {
@@ -48,8 +130,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (index >= 0 && index < trackState.tracks.length) {
final track = trackState.tracks[index];
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
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')));
});
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
}
@@ -57,88 +147,179 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
});
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
}
}
bool get _hasResults {
final trackState = ref.watch(trackProvider);
return trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading;
}
@override
Widget build(BuildContext context) {
super.build(context);
final trackState = ref.watch(trackProvider);
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect) {
final colorScheme = Theme.of(context).colorScheme;
final hasResults = _hasResults;
return Scaffold(
body: SafeArea(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: hasResults
? _buildResultsView(trackState, colorScheme)
: _buildCenteredSearch(colorScheme),
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
_QualityPickerOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
),
const SizedBox(height: 16),
],
),
),
);
}
// Centered search view when no results
Widget _buildCenteredSearch(ColorScheme colorScheme) {
final historyItems = ref.watch(downloadHistoryProvider).items;
bool get _hasResults {
final trackState = ref.watch(trackProvider);
// Show results view when typing, loading, or has results
return _isTyping || trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading;
}
@override
Widget build(BuildContext context) {
super.build(context);
return Center(
key: const ValueKey('centered'),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App icon/logo
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
),
const SizedBox(height: 24),
Text(
'Search Music',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Paste a Spotify link or search by name',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
// Search bar
_buildSearchBar(colorScheme),
const SizedBox(height: 12),
// Helper text
if (!ref.watch(settingsProvider).hasSearchedBefore)
Text(
'Supports: Track, Album, Playlist, Artist URLs',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
// Listen for state changes to sync search bar
ref.listen<TrackState>(trackProvider, _onTrackStateChanged);
final trackState = ref.watch(trackProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasResults = _hasResults;
final screenHeight = MediaQuery.of(context).size.height;
final historyItems = ref.watch(downloadHistoryProvider).items;
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar - always present
SliverAppBar(
expandedHeight: 130,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.3,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Search',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
// Recent downloads - compact horizontal scroll
if (historyItems.isNotEmpty) ...[
const SizedBox(height: 32),
_buildRecentDownloads(historyItems, colorScheme),
],
],
),
),
),
// Idle content (logo, title) - always in tree, animated size
SliverToBoxAdapter(
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
child: hasResults
? const SizedBox.shrink()
: Column(
children: [
SizedBox(height: screenHeight * 0.06),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
),
const SizedBox(height: 16),
Text(
'Search Music',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Paste a Spotify link or search by name',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
// Search bar - always present at same position in tree
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16),
child: _buildSearchBar(colorScheme),
),
),
// Idle content below search bar - always in tree
SliverToBoxAdapter(
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
child: hasResults
? const SizedBox.shrink()
: Column(
children: [
if (!ref.watch(settingsProvider).hasSearchedBefore)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Supports: Track, Album, Playlist, Artist URLs',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
if (historyItems.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
child: _buildRecentDownloads(historyItems, colorScheme),
),
],
),
),
),
// Results content - always in tree
..._buildResultsContent(trackState, colorScheme, hasResults),
],
),
);
}
@@ -220,93 +401,63 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
));
}
// Results view with search bar at top
Widget _buildResultsView(TrackState trackState, ColorScheme colorScheme) {
return RefreshIndicator(
key: const ValueKey('results'),
onRefresh: _clearAndRefresh,
displacement: 100,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Collapsing App Bar
SliverAppBar(
expandedHeight: 100,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.4,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Search',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
// Results content slivers (without app bar and search bar)
List<Widget> _buildResultsContent(TrackState trackState, ColorScheme colorScheme, bool hasResults) {
// Return empty slivers when no results to keep tree structure stable
if (!hasResults) {
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
}
return [
// Error message
if (trackState.error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
)),
// Search bar at top
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: _buildSearchBar(colorScheme),
),
),
// Loading indicator
if (trackState.isLoading)
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
// Error message
if (trackState.error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
)),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
// Loading indicator
if (trackState.isLoading)
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
// Artist header and discography
if (trackState.artistName != null && trackState.artistAlbums != null)
SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty)
SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)),
// Artist header and discography
if (trackState.artistName != null && trackState.artistAlbums != null)
SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)),
// Download All button
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null && trackState.artistAlbums == null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
label: Text('Download All (${trackState.tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
)),
if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty)
SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)),
// Track list
SliverList(delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackTile(index, colorScheme),
childCount: trackState.tracks.length,
)),
// Download All button
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null && trackState.artistAlbums == null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
label: Text('Download All (${trackState.tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
)),
// Track list
SliverList(delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackTile(index, colorScheme),
childCount: trackState.tracks.length,
)),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
);
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
];
}
Widget _buildSearchBar(ColorScheme colorScheme) {
final hasText = _urlController.text.isNotEmpty;
return TextField(
controller: _urlController,
focusNode: _searchFocusNode,
autofocus: false,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
filled: true,
@@ -323,30 +474,22 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
prefixIcon: const Icon(Icons.link),
prefixIcon: const Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.paste),
onPressed: _pasteFromClipboard,
tooltip: 'Paste',
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20),
),
onPressed: _fetchMetadata,
tooltip: 'Search',
if (hasText)
IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearAndRefresh,
tooltip: 'Clear',
)
else
IconButton(
icon: const Icon(Icons.paste),
onPressed: _pasteFromClipboard,
tooltip: 'Paste',
),
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
@@ -555,3 +698,22 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
);
}
}
class _QualityPickerOption extends StatelessWidget {
final String title;
final String subtitle;
final VoidCallback onTap;
const _QualityPickerOption({required this.title, required this.subtitle, 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: Icon(Icons.music_note, color: colorScheme.primary),
title: Text(title),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
onTap: onTap,
);
}
}
+17 -2
View File
@@ -11,6 +11,9 @@ import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MainShell');
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@@ -40,13 +43,13 @@ class _MainShellState extends ConsumerState<MainShell> {
// Check for pending URL that was received before listener was ready
final pendingUrl = ShareIntentService().consumePendingUrl();
if (pendingUrl != null) {
print('[MainShell] Processing pending shared URL: $pendingUrl');
_log.d('Processing pending shared URL: $pendingUrl');
_handleSharedUrl(pendingUrl);
}
// Listen for future shared URLs
_shareSubscription = ShareIntentService().sharedUrlStream.listen((url) {
print('[MainShell] Received shared URL from stream: $url');
_log.d('Received shared URL from stream: $url');
_handleSharedUrl(url);
});
}
@@ -147,12 +150,24 @@ class _MainShellState extends ConsumerState<MainShell> {
return;
}
// If on Search tab and has text in search bar or has content (but not loading), clear it
// Don't clear while loading - this prevents clearing during share intent processing
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Search tab, go to Search tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Already at root, show exit dialog
final shouldPop = await _showExitDialog();
if (shouldPop && context.mounted) {
+3 -3
View File
@@ -78,7 +78,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
slivers: [
// Collapsing App Bar - Simplified for performance
SliverAppBar(
expandedHeight: 100,
expandedHeight: 130,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
@@ -86,12 +86,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.4,
expandedTitleScale: 1.3,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'History',
style: TextStyle(
fontSize: 20,
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
+1 -1
View File
@@ -69,7 +69,7 @@ class AboutPage extends StatelessWidget {
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)),
errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
),
),
const SizedBox(width: 16),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AppearanceSettingsPage extends ConsumerWidget {
const AppearanceSettingsPage({super.key});
@@ -56,46 +57,50 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
// Theme section
SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
child: SettingsGroup(
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
],
),
),
// Color section
SliverToBoxAdapter(child: _SectionHeader(title: 'Color')),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
SliverToBoxAdapter(
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.auto_awesome,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
showDivider: !themeSettings.useDynamicColor,
),
if (!themeSettings.useDynamicColor)
_ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
),
],
),
),
if (!themeSettings.useDynamicColor)
SliverToBoxAdapter(
child: _ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
),
),
// Layout section
SliverToBoxAdapter(child: _SectionHeader(title: 'Layout')),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
],
),
),
@@ -107,17 +112,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
);
}
class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged;
@@ -125,21 +119,15 @@ class _ThemeModeSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(children: [
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
]),
),
return Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
const SizedBox(width: 8),
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
]),
);
}
}
@@ -154,9 +142,16 @@ class _ThemeModeChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need to be darker than the card background
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
@@ -191,9 +186,9 @@ class _ColorPicker extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
@@ -224,26 +219,21 @@ class _HistoryViewSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text('History View', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
),
Row(children: [
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
const SizedBox(width: 8),
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
]),
],
),
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
),
Row(children: [
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
const SizedBox(width: 8),
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
]),
],
),
);
}
@@ -259,9 +249,15 @@ class _ViewModeChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
+126 -84
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerWidget {
const DownloadSettingsPage({super.key});
@@ -55,59 +56,82 @@ class DownloadSettingsPage extends ConsumerWidget {
),
// Service section
SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _ServiceSelector(
currentService: settings.defaultService,
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
),
child: SettingsGroup(
children: [
_ServiceSelector(
currentService: settings.defaultService,
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
),
],
),
),
// Quality section
SliverToBoxAdapter(child: _SectionHeader(title: 'Audio Quality')),
SliverList(delegate: SliverChildListDelegate([
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', value: 'LOSSLESS',
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS')),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', value: 'HI_RES',
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES')),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', value: 'HI_RES_LOSSLESS',
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS')),
])),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
subtitle: 'Choose quality for each download',
value: settings.askQualityBeforeDownload,
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value),
),
if (!settings.askQualityBeforeDownload) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'),
),
_QualityOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'),
showDivider: false,
),
],
],
),
),
// File settings section
SliverToBoxAdapter(child: _SectionHeader(title: 'File Settings')),
SliverList(delegate: SliverChildListDelegate([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.text_fields, color: colorScheme.onSurfaceVariant),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'File Settings')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
onTap: () => _pickDirectory(ref),
),
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
subtitle: _getFolderOrganizationLabel(settings.folderOrganization),
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
showDivider: false,
),
],
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.folder_outlined, color: colorScheme.onSurfaceVariant),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: const Icon(Icons.chevron_right),
onTap: () => _pickDirectory(ref),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.create_new_folder_outlined, color: colorScheme.onSurfaceVariant),
title: const Text('Folder Organization'),
subtitle: Text(_getFolderOrganizationLabel(settings.folderOrganization)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
),
])),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
@@ -150,13 +174,13 @@ class DownloadSettingsPage extends ConsumerWidget {
String _getFolderOrganizationLabel(String value) {
switch (value) {
case 'artist':
return 'By Artist (Artist/Track.flac)';
return 'By Artist';
case 'album':
return 'By Album (Album/Track.flac)';
return 'By Album';
case 'artist_album':
return 'By Artist & Album (Artist/Album/Track.flac)';
return 'By Artist & Album';
default:
return 'None (all in one folder)';
return 'None';
}
}
@@ -215,17 +239,6 @@ class DownloadSettingsPage extends ConsumerWidget {
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
);
}
class _ServiceSelector extends StatelessWidget {
final String currentService;
final ValueChanged<String> onChanged;
@@ -233,21 +246,15 @@ class _ServiceSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(children: [
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
const SizedBox(width: 8),
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
const SizedBox(width: 8),
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
]),
),
return Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
const SizedBox(width: 8),
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
const SizedBox(width: 8),
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
]),
);
}
}
@@ -262,9 +269,15 @@ class _ServiceChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
@@ -288,20 +301,49 @@ class _ServiceChip extends StatelessWidget {
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final String value;
final bool isSelected;
final VoidCallback onTap;
const _QualityOption({required this.title, required this.subtitle, required this.value, required this.isSelected, required this.onTap});
final bool showDivider;
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: onTap,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
),
isSelected
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 20,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
+88 -73
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget {
const OptionsSettingsPage({super.key});
@@ -55,78 +56,96 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Download options section
SliverToBoxAdapter(child: _SectionHeader(title: 'Download')),
SliverList(delegate: SliverChildListDelegate([
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: Icon(Icons.sync, color: colorScheme.onSurfaceVariant),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.sync,
title: 'Auto Fallback',
subtitle: 'Try other services if download fails',
value: settings.autoFallback,
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
),
SettingsSwitchItem(
icon: Icons.lyrics,
title: 'Embed Lyrics',
subtitle: 'Embed synced lyrics into FLAC files',
value: settings.embedLyrics,
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
),
SettingsSwitchItem(
icon: Icons.image,
title: 'Max Quality Cover',
subtitle: 'Download highest resolution cover art',
value: settings.maxQualityCover,
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
showDivider: false,
),
],
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: Icon(Icons.lyrics, color: colorScheme.onSurfaceVariant),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: Icon(Icons.image, color: colorScheme.onSurfaceVariant),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
),
])),
),
// Performance section
SliverToBoxAdapter(child: _SectionHeader(title: 'Performance')),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Performance')),
SliverToBoxAdapter(
child: _ConcurrentDownloadsSelector(
currentValue: settings.concurrentDownloads,
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
child: SettingsGroup(
children: [
_ConcurrentDownloadsItem(
currentValue: settings.concurrentDownloads,
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
),
],
),
),
// Lyrics section
SliverToBoxAdapter(child: _SectionHeader(title: 'Lyrics')),
SliverList(delegate: SliverChildListDelegate([
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: Icon(Icons.translate, color: colorScheme.onSurfaceVariant),
title: const Text('Convert Japanese to Romaji'),
subtitle: const Text('Auto-convert Hiragana/Katakana lyrics'),
value: settings.convertLyricsToRomaji,
onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Lyrics')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.translate,
title: 'Convert Japanese to Romaji',
subtitle: 'Auto-convert Hiragana/Katakana lyrics',
value: settings.convertLyricsToRomaji,
onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v),
showDivider: false,
),
],
),
])),
),
// App section
SliverToBoxAdapter(child: _SectionHeader(title: 'App')),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
SliverToBoxAdapter(
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: Icon(Icons.system_update, color: colorScheme.onSurfaceVariant),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.system_update,
title: 'Check for Updates',
subtitle: 'Notify when new version is available',
value: settings.checkForUpdates,
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
showDivider: false,
),
],
),
),
// Data section
SliverToBoxAdapter(child: _SectionHeader(title: 'Data')),
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
SliverToBoxAdapter(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.delete_forever, color: colorScheme.error),
title: const Text('Clear Download History'),
subtitle: const Text('Remove all downloaded tracks from history'),
onTap: () => _showClearHistoryDialog(context, ref, colorScheme),
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.delete_forever,
title: 'Clear Download History',
subtitle: 'Remove all downloaded tracks from history',
onTap: () => _showClearHistoryDialog(context, ref, colorScheme),
showDivider: false,
),
],
),
),
@@ -163,35 +182,25 @@ class OptionsSettingsPage extends ConsumerWidget {
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
);
}
class _ConcurrentDownloadsSelector extends StatelessWidget {
class _ConcurrentDownloadsItem extends StatelessWidget {
final int currentValue;
final ValueChanged<int> onChanged;
const _ConcurrentDownloadsSelector({required this.currentValue, required this.onChanged});
const _ConcurrentDownloadsItem({required this.currentValue, required this.onChanged});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant, size: 24),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Concurrent Downloads'),
Text('Concurrent Downloads', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
]),
const SizedBox(height: 16),
@@ -223,9 +232,15 @@ class _ConcurrentChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
+46 -64
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@@ -15,9 +16,9 @@ class SettingsTab extends ConsumerWidget {
return CustomScrollView(
slivers: [
// Collapsing App Bar - Simplified for performance
// Collapsing App Bar
SliverAppBar(
expandedHeight: 100,
expandedHeight: 130,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
@@ -25,12 +26,12 @@ class SettingsTab extends ConsumerWidget {
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.4,
expandedTitleScale: 1.3,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Settings',
style: TextStyle(
fontSize: 20,
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@@ -38,35 +39,50 @@ class SettingsTab extends ConsumerWidget {
),
),
// Menu items
SliverList(delegate: SliverChildListDelegate([
_SettingsMenuItem(
icon: Icons.palette_outlined,
title: 'Appearance',
subtitle: 'Theme, colors, display',
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
// First group: Appearance & Download
SliverToBoxAdapter(
child: SettingsGroup(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
children: [
SettingsItem(
icon: Icons.palette_outlined,
title: 'Appearance',
subtitle: 'Theme, colors, display',
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
),
SettingsItem(
icon: Icons.download_outlined,
title: 'Download',
subtitle: 'Service, quality, filename format',
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: 'Options',
subtitle: 'Fallback, lyrics, cover art, updates',
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
showDivider: false,
),
],
),
_SettingsMenuItem(
icon: Icons.download_outlined,
title: 'Download',
subtitle: 'Service, quality, filename format',
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
// Second group: About
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.info_outline,
title: 'About',
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
],
),
_SettingsMenuItem(
icon: Icons.tune_outlined,
title: 'Options',
subtitle: 'Fallback, lyrics, cover art, updates',
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
),
_SettingsMenuItem(
icon: Icons.info_outline,
title: 'About',
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
onTap: () => _navigateTo(context, const AboutPage()),
),
])),
),
// Fill remaining space to enable scroll
// Fill remaining space
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
);
@@ -76,37 +92,3 @@ class SettingsTab extends ConsumerWidget {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
}
}
class _SettingsMenuItem extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsMenuItem({required this.icon, required this.title, required this.subtitle, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 22),
),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: 24),
]),
),
);
}
}
+1 -1
View File
@@ -194,7 +194,7 @@ class SettingsScreen extends ConsumerWidget {
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
+1 -1
View File
@@ -203,7 +203,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
+22 -2
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -191,7 +192,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
placeholder: (_, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
@@ -854,7 +855,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
title: const Text('Share'),
onTap: () {
Navigator.pop(context);
// TODO: Implement share
_shareFile(context);
},
),
ListTile(
@@ -926,6 +927,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Future<void> _shareFile(BuildContext context) async {
final file = File(item.filePath);
if (!await file.exists()) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('File not found')),
);
}
return;
}
await SharePlus.instance.share(
ShareParams(
files: [XFile(item.filePath)],
text: '${item.trackName} - ${item.artistName}',
),
);
}
String _formatFullDate(DateTime date) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+9 -6
View File
@@ -2,6 +2,9 @@ import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ApkDownloader');
typedef ProgressCallback = void Function(int received, int total);
@@ -17,7 +20,7 @@ class ApkDownloader {
final response = await client.send(request);
if (response.statusCode != 200) {
print('[ApkDownloader] Failed to download: ${response.statusCode}');
_log.e('Failed to download: ${response.statusCode}');
return null;
}
@@ -26,7 +29,7 @@ class ApkDownloader {
// Get download directory
final dir = await getExternalStorageDirectory();
if (dir == null) {
print('[ApkDownloader] Could not get storage directory');
_log.e('Could not get storage directory');
return null;
}
@@ -50,10 +53,10 @@ class ApkDownloader {
await sink.close();
client.close();
print('[ApkDownloader] Downloaded to: $filePath');
_log.i('Downloaded to: $filePath');
return filePath;
} catch (e) {
print('[ApkDownloader] Error: $e');
_log.e('Error: $e');
return null;
}
}
@@ -61,9 +64,9 @@ class ApkDownloader {
static Future<void> installApk(String filePath) async {
try {
final result = await OpenFilex.open(filePath);
print('[ApkDownloader] Open result: ${result.type} - ${result.message}');
_log.i('Open result: ${result.type} - ${result.message}');
} catch (e) {
print('[ApkDownloader] Install error: $e');
_log.e('Install error: $e');
}
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
/// FFmpeg service for audio conversion and remuxing
class FFmpegService {
@@ -27,7 +30,7 @@ class FFmpegService {
// Log error for debugging
final logs = await session.getLogs();
for (final log in logs) {
print('[FFmpeg] ${log.getMessage()}');
_log.d(log.getMessage());
}
return null;
+41
View File
@@ -234,4 +234,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;
}
}
+5 -2
View File
@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ShareIntent');
/// Service to handle incoming share intents from other apps (e.g., Spotify)
class ShareIntentService {
@@ -30,7 +33,7 @@ class ShareIntentService {
// Listen to media sharing coming from outside the app while the app is in memory
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
_handleSharedMedia,
onError: (err) => print('[ShareIntent] Error: $err'),
onError: (err) => _log.e('Error: $err'),
);
// Get the media sharing coming from outside the app while the app is closed
@@ -49,7 +52,7 @@ class ShareIntentService {
final url = _extractSpotifyUrl(textToCheck);
if (url != null) {
print('[ShareIntent] Received Spotify URL: $url (initial: $isInitial)');
_log.i('Received Spotify URL: $url (initial: $isInitial)');
if (isInitial) {
// Store for later - listener might not be ready yet
_pendingUrl = url;
+25 -22
View File
@@ -2,12 +2,15 @@ import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('UpdateChecker');
class UpdateInfo {
final String version;
final String changelog;
final String downloadUrl;
final String? apkDownloadUrl; // Direct APK download URL
final String? apkDownloadUrl;
final DateTime publishedAt;
const UpdateInfo({
@@ -22,20 +25,16 @@ class UpdateInfo {
class UpdateChecker {
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
/// Get device CPU architecture
static Future<String> _getDeviceArch() async {
if (!Platform.isAndroid) return 'unknown';
try {
// Read CPU info from /proc/cpuinfo
final cpuInfo = await File('/proc/cpuinfo').readAsString();
// Check for 64-bit indicators
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
return 'arm64';
}
// Check architecture from uname
final result = await Process.run('uname', ['-m']);
final arch = result.stdout.toString().trim().toLowerCase();
@@ -49,14 +48,13 @@ class UpdateChecker {
return 'x86';
}
return 'arm64'; // Default to arm64 for modern devices
return 'arm64';
} catch (e) {
print('[UpdateChecker] Error detecting arch: $e');
return 'arm64'; // Default fallback
_log.e('Error detecting arch: $e');
return 'arm64';
}
}
/// Check for updates from GitHub releases
static Future<UpdateInfo?> checkForUpdate() async {
try {
final response = await http.get(
@@ -65,7 +63,7 @@ class UpdateChecker {
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
print('[UpdateChecker] GitHub API returned ${response.statusCode}');
_log.w('GitHub API returned ${response.statusCode}');
return null;
}
@@ -74,18 +72,16 @@ class UpdateChecker {
final latestVersion = tagName.replaceFirst('v', '');
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
print('[UpdateChecker] No update available (current: ${AppInfo.version}, latest: $latestVersion)');
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
return null;
}
// Get changelog from release body
final body = data['body'] as String? ?? 'No changelog available';
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
// Find APK download URL from assets based on device architecture
final deviceArch = await _getDeviceArch();
print('[UpdateChecker] Device architecture: $deviceArch');
_log.d('Device architecture: $deviceArch');
String? arm64Url;
String? arm32Url;
@@ -106,7 +102,6 @@ class UpdateChecker {
}
}
// Select APK based on device architecture
String? apkUrl;
if (deviceArch == 'arm64') {
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
@@ -116,7 +111,7 @@ class UpdateChecker {
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
}
print('[UpdateChecker] Update available: $latestVersion, APK URL: $apkUrl');
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
return UpdateInfo(
version: latestVersion,
@@ -126,18 +121,19 @@ class UpdateChecker {
publishedAt: publishedAt,
);
} catch (e) {
print('[UpdateChecker] Error checking for updates: $e');
_log.e('Error checking for updates: $e');
return null;
}
}
/// Compare version strings (e.g., "1.1.1" vs "1.1.0")
static bool _isNewerVersion(String latest, String current) {
try {
final latestParts = latest.split('.').map(int.parse).toList();
final currentParts = current.split('.').map(int.parse).toList();
final latestBase = latest.split('-').first;
final currentBase = current.split('-').first;
final latestParts = latestBase.split('.').map(int.parse).toList();
final currentParts = currentBase.split('.').map(int.parse).toList();
// Pad with zeros if needed
while (latestParts.length < 3) {
latestParts.add(0);
}
@@ -149,8 +145,15 @@ class UpdateChecker {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false; // Same version
final latestHasSuffix = latest.contains('-');
final currentHasSuffix = current.contains('-');
if (!latestHasSuffix && currentHasSuffix) return true;
return false;
} catch (e) {
_log.e('Error comparing versions: $e');
return false;
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:logger/logger.dart';
/// Global logger instance for the app
/// Uses pretty printer in debug mode for readable output
final log = Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
lineLength: 80,
colors: true,
printEmojis: false,
dateTimeFormat: DateTimeFormat.none,
),
level: Level.debug,
);
/// Logger with class/tag prefix for better traceability
class AppLogger {
final String _tag;
AppLogger(this._tag);
void d(String message) => log.d('[$_tag] $message');
void i(String message) => log.i('[$_tag] $message');
void w(String message) => log.w('[$_tag] $message');
void e(String message, [Object? error, StackTrace? stackTrace]) =>
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
}
+226
View File
@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
/// A grouped settings card that connects items together like Android Settings
/// Items are connected with no gap between them, only separated when changing groups
class SettingsGroup extends StatelessWidget {
final List<Widget> children;
final EdgeInsetsGeometry? margin;
const SettingsGroup({
super.key,
required this.children,
this.margin,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// 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
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest;
return Container(
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
),
);
}
}
/// A single settings item that can be used inside SettingsGroup
class SettingsItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
final bool showDivider;
const SettingsItem({
super.key,
this.icon,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
const SizedBox(width: 16),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
] else if (onTap != null) ...[
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: icon != null ? 56 : 20,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
/// A switch settings item for SettingsGroup
class SettingsSwitchItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
final bool showDivider;
const SettingsSwitchItem({
super.key,
this.icon,
required this.title,
this.subtitle,
required this.value,
this.onChanged,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onChanged != null ? () => onChanged!(!value) : null,
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: [
if (icon != null) ...[
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
const SizedBox(width: 16),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 8),
Switch(
value: value,
onChanged: onChanged,
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: icon != null ? 56 : 20,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
/// Section header for settings groups
class SettingsSectionHeader extends StatelessWidget {
final String title;
const SettingsSectionHeader({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
}
+46 -54
View File
@@ -5,26 +5,26 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "85.0.0"
version: "91.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "7.6.0"
version: "8.4.1"
analyzer_buffer:
dependency: transitive
description:
name: analyzer_buffer
sha256: f7833bee67c03c37241c67f8741b17cc501b69d9758df7a5a4a13ed6c947be43
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
url: "https://pub.dev"
source: hosted
version: "0.1.10"
version: "0.1.11"
archive:
dependency: transitive
description:
@@ -61,18 +61,18 @@ packages:
dependency: transitive
description:
name: build
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "4.0.3"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.2.0"
build_daemon:
dependency: transitive
description:
@@ -81,30 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
url: "https://pub.dev"
source: hosted
version: "9.2.1"
version: "2.10.4"
built_collection:
dependency: transitive
description:
@@ -245,10 +229,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.1.3"
dbus:
dependency: transitive
description:
@@ -386,26 +370,34 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
url: "https://pub.dev"
source: hosted
version: "18.0.1"
version: "19.5.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
url: "https://pub.dev"
source: hosted
version: "5.0.0"
version: "6.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
version: "9.1.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -576,6 +568,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
logger:
dependency: "direct main"
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging:
dependency: transitive
description:
@@ -620,10 +620,10 @@ packages:
dependency: transitive
description:
name: mockito
sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99"
sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e
url: "https://pub.dev"
source: hosted
version: "5.5.0"
version: "5.6.1"
node_preamble:
dependency: transitive
description:
@@ -876,18 +876,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
url: "https://pub.dev"
source: hosted
version: "10.1.4"
version: "12.0.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "6.1.0"
shared_preferences:
dependency: "direct main"
description:
@@ -985,18 +985,18 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3"
sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "4.1.1"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
url: "https://pub.dev"
source: hosted
version: "1.3.7"
version: "1.3.8"
source_map_stack_trace:
dependency: transitive
description:
@@ -1149,14 +1149,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.10.1"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
+5 -4
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 1.5.5+22
version: 1.6.1+26
environment:
sdk: ^3.10.0
@@ -46,21 +46,22 @@ dependencies:
# Utils
url_launcher: ^6.3.1
device_info_plus: ^12.3.0
share_plus: ^10.1.4
share_plus: ^12.0.1
receive_sharing_intent: ^1.8.1
logger: ^2.5.0
# FFmpeg for audio conversion (audio-only version - much smaller)
ffmpeg_kit_flutter_new_audio: ^2.0.0
open_filex: ^4.7.0
# Notifications
flutter_local_notifications: ^18.0.1
flutter_local_notifications: ^19.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
build_runner: ^2.4.15
build_runner: ^2.10.4
riverpod_generator: ^4.0.0
json_serializable: ^6.11.2
flutter_launcher_icons: ^0.14.3