Compare commits

..

21 Commits

Author SHA1 Message Date
zarzet 3b2ec319e2 Fix pubspec version to 2.1.0+41 2026-01-07 00:19:53 +07:00
zarzet a0f7e75a9a Update VirusTotal badge to 2.1.0 2026-01-07 00:15:43 +07:00
zarzet c725e53e4c Release 2.1.0 2026-01-07 00:00:28 +07:00
zarzet 1d7c43a302 v2.1.0-preview: Download speed optimizations 2026-01-06 03:56:26 +07:00
Zarz Eleutherius df7c1c5bb7 Add VirusTotal badge to README
Added VirusTotal badge to README for safety verification.
2026-01-06 02:05:50 +07:00
zarzet bb05353b7e fix(ios): directory picker - add App Documents option for iOS
- iOS limitation: empty folders cannot be selected via document picker
- Added bottom sheet with App Documents Folder as recommended option
- Shows info message explaining iOS limitation
- Files accessible via iOS Files app

Version: 2.0.7-preview2+38
2026-01-06 00:23:19 +07:00
zarzet 7ac92d77e5 chore: add custom FFmpeg AAR to repo for CI builds 2026-01-05 14:23:41 +07:00
zarzet cf00ecb756 feat: use custom FFmpeg AAR for Android, reduce APK size
- Replace ffmpeg_kit_flutter plugin with custom AAR (arm64 + arm7a only)
- Add MethodChannel bridge for FFmpeg in MainActivity
- Create separate pubspec_ios.yaml for iOS builds with ffmpeg_kit plugin
- Update GitHub workflow to swap pubspec for iOS builds
- Reduces Android APK size by ~50MB
2026-01-05 14:09:32 +07:00
zarzet 525f2fd0cd chore: bump version to 2.0.6+36 2026-01-05 12:21:05 +07:00
zarzet 3e841cef06 fix: duration display, audio quality from file, artist verification, metadata case-sensitivity, settings navigation freeze
- Fix duration showing incorrect values (ms to seconds conversion)
- Read audio quality from FLAC file instead of trusting API
- Add artist verification for Tidal/Qobuz/Amazon downloads
- Fix FLAC metadata case-insensitive replacement
- Fix settings navigation freeze on Android 14+ (PopScope handling)
2026-01-05 10:30:57 +07:00
zarzet a8527df80a docs: application stabilized, remove dev notice 2026-01-05 03:12:30 +07:00
zarzet 51b2ad5c77 v2.0.5: Large playlist support + duration verification fix
- Add pagination for playlists (up to 1000 tracks)
- Add duration verification to prevent wrong track downloads
- When Tidal returns wrong version, fallback to Qobuz/Amazon
2026-01-05 03:08:15 +07:00
zarzet d641a517b8 ci: Fix disk cleanup - only remove safe directories 2026-01-04 12:09:13 +07:00
zarzet 608fa2ca74 ci: Fix disk cleanup - don't remove Android SDK path 2026-01-04 12:08:18 +07:00
zarzet 343b309314 ci: Add disk cleanup step to fix runner space issue 2026-01-04 12:01:34 +07:00
zarzet 0787b32dd8 v2.0.4: Fix Android 11 storage permission denied 2026-01-04 11:51:09 +07:00
zarzet 6927fdf7a9 fix: Android 11 storage permission denied issue 2026-01-04 11:48:19 +07:00
zarzet fe6af34478 Update screenshots 2026-01-04 00:14:33 +07:00
zarzet 85bb67da47 v2.0.3: Custom Spotify credentials, rate limit UI, search fixes 2026-01-03 23:56:03 +07:00
zarzet 794486a200 v2.0.2: Quality display, fallback fix, Open in Spotify fix
- Add actual quality display (bit depth/sample rate) in history and metadata
- Add quality disclaimer in quality picker
- Fix fallback service display showing wrong service
- Fix Open in Spotify not opening app correctly
- Remove romaji conversion feature
- Amazon now reads quality from FLAC file
2026-01-03 07:23:54 +07:00
zarzet 8ce5e958ee docs: update changelog 2026-01-03 05:46:03 +07:00
55 changed files with 3844 additions and 1102 deletions
+31
View File
@@ -45,6 +45,20 @@ jobs:
needs: get-version
steps:
- name: Free disk space
run: |
# Remove large unused tools (~15GB total)
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/.ghcup
# Clean docker images
sudo docker image prune --all --force
# Show available space
df -h
- name: Checkout repository
uses: actions/checkout@v4
@@ -215,6 +229,23 @@ jobs:
channel: 'stable'
cache: true
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
- name: Use iOS pubspec with FFmpeg plugin
run: |
cp pubspec.yaml pubspec_android_backup.yaml
cp pubspec_ios.yaml pubspec.yaml
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
# Swap FFmpeg service for iOS
- name: Use iOS FFmpeg service
run: |
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
# Update class name in the swapped file
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
echo "Swapped to iOS FFmpeg service"
- name: Get Flutter dependencies
run: flutter pub get
+2 -4
View File
@@ -13,9 +13,6 @@ Thumbs.db
# Reference folder (development only)
referensi/
# Development notes
COMPARISON_PC_vs_ANDROID.md
# Old spotiflac_android folder (moved to root)
spotiflac_android/
@@ -38,7 +35,7 @@ go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/
android/app/libs/gobackend.aar
android/local.properties
android/*.iml
android/key.properties
@@ -52,3 +49,4 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
+199 -6
View File
@@ -1,5 +1,201 @@
# Changelog
## [2.1.0] - 2026-01-06
### Added
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
- Change service on-the-fly without going to settings
- Available in Home, Album, and Playlist screens
- **AMOLED Dark Theme**: Pure black background for OLED screens
- Toggle in Settings > Appearance > Theme
- Saves battery on OLED/AMOLED displays
- All surface colors adjusted for true black background
- **Update Channel Setting**: Choose between Stable and Preview release channels
- Stable: Only receive stable release notifications
- Preview: Get notified about preview/beta releases too
- Configure in Settings > Options > App
### Changed
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
- Only includes FLAC, MP3 (LAME), and AAC codecs
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- Native MethodChannel bridge for FFmpeg operations
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
### Fixed
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- Shows "Lyrics not available" instead of loading forever
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- iOS limitation: Empty folders cannot be selected via document picker
- Added "App Documents Folder" option as recommended default
- Files saved to app Documents folder are accessible via iOS Files app
### Performance
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
- ISRC search first strategy (faster than SongLink API)
- Track ID cache with 30 minute TTL for album/playlist downloads
- Pre-warm cache when viewing album/playlist
- Parallel cover art and lyrics fetching during audio download
- 64KB HTTP read/write buffers
- 256KB buffered file writer for all downloaders
- Progress updates every 64KB (reduced lock contention)
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
## [2.1.0-preview2] - 2026-01-06
### Added
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
- Change service on-the-fly without going to settings
- Available in Home, Album, and Playlist screens
- **AMOLED Dark Theme**: Pure black background for OLED screens
- Toggle in Settings > Appearance > Theme
- Saves battery on OLED/AMOLED displays
- All surface colors adjusted for true black background
- **Update Channel Setting**: Choose between Stable and Preview release channels
- Stable: Only receive stable release notifications
- Preview: Get notified about preview/beta releases too
- Configure in Settings > Options > App
### Fixed
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
- Added logging for better debugging
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- Shows "Lyrics not available" instead of loading forever
- Better error messages for timeout and not found cases
## [2.1.0-preview] - 2026-01-06
### Performance
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
- ISRC search first strategy (faster than SongLink API)
- Track ID cache with 30 minute TTL for album/playlist downloads
- Pre-warm cache when viewing album/playlist
- Parallel cover art and lyrics fetching during audio download
- 64KB HTTP read/write buffers
- 256KB buffered file writer for all downloaders
- Progress updates every 64KB (reduced lock contention)
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
### Technical
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
## [2.0.7-preview2] - 2026-01-06
### Fixed
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- iOS limitation: Empty folders cannot be selected via document picker
- Added "App Documents Folder" option as recommended default
- Shows info message explaining iOS limitation
- Files saved to app Documents folder are accessible via iOS Files app
## [2.0.7-preview] - 2026-01-05
### Changed
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
- Only includes FLAC, MP3 (LAME), and AAC codecs
- Removed x86/x86_64 architectures (emulator only)
### Technical
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- Native MethodChannel bridge for FFmpeg operations
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
## [2.0.6] - 2026-01-05
### Fixed
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
- Now properly converts milliseconds to seconds before display
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
- Also reads quality from existing files when skipping duplicates
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
- Verifies artist matches between Spotify metadata and streaming service
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
- Applied to Tidal, Qobuz, and Amazon downloads
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
- Now uses case-insensitive comparison when replacing existing Vorbis comments
- Fixes issue where Amazon downloads could have duplicate metadata tags
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
- Added proper PopScope handling for predictive back gesture on Android 14+
## [2.0.5] - 2026-01-05
### Added
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
### Fixed
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
## [2.0.4] - 2026-01-04
### Fixed
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
- Shows explanation dialog before opening system settings
## [2.0.3] - 2026-01-03
### Added
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
- Toggle to enable/disable custom credentials without deleting them
- Material Expressive 3 bottom sheet UI for entering credentials
- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
### Changed
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
### Fixed
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
## [2.0.2] - 2026-01-03
### Added
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
- Quality badge on download history items (e.g., "24-bit", "16-bit")
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
- Tertiary color highlight for Hi-Res (24-bit) downloads
- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
### Fixed
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
### Removed
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
### Technical
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
- Go backend now returns `service` field indicating actual service used (important for fallback)
- Tidal API v2 response provides exact quality info
- Qobuz uses track metadata for quality info
- Amazon now reads quality from downloaded FLAC file (previously returned unknown)
## [2.0.1] - 2026-01-03
### Added
@@ -9,12 +205,6 @@
- Ripple effect follows rounded corners including drag handle
### Changed
- **Update Dialog Redesign**: Material Expressive 3 style
- Icon header with container
- Version chips with "Current" and "New" labels
- Changelog in rounded card
- Download progress with percentage indicator
- Cleaner button layout
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
- All downloads now use item-based progress tracking
- Fixes duplicate notification bug when finalizing
@@ -23,6 +213,9 @@
### Fixed
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
- Container with `primaryContainer` background for each option
- Distinct icons: music_note (Lossless), high_quality (Hi-Res), four_k (Max)
## [2.0.0] - 2026-01-03
+5 -6
View File
@@ -1,4 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/9092dd9300289ceadd8e70cd71706a3ba32225d9cb2ae8b12648611d31814708)
<div align="center">
@@ -11,17 +12,15 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
</div>
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Screenshots
<p align="center">
<img src="assets/images/1.jpg" width="200" />
<img src="assets/images/2.jpg" width="200" />
<img src="assets/images/3.jpg" width="200" />
<img src="assets/images/4.jpg" width="200" />
<img src="assets/images/1.jpg?v=2" width="200" />
<img src="assets/images/2.jpg?v=2" width="200" />
<img src="assets/images/3.jpg?v=2" width="200" />
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## Other project
+1
View File
@@ -95,6 +95,7 @@ repositories {
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(files("libs/gobackend.aar"))
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
}
Binary file not shown.
+3 -1
View File
@@ -4,9 +4,11 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- For Android 11+ (API 30-32) - full storage access -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -5,6 +5,8 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import gobackend.Gobackend
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -13,6 +15,7 @@ import kotlinx.coroutines.withContext
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend"
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onNewIntent(intent: Intent) {
@@ -157,8 +160,9 @@ class MainActivity: FlutterActivity() {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
}
result.success(response)
}
@@ -199,6 +203,33 @@ class MainActivity: FlutterActivity() {
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
"setSpotifyCredentials" -> {
val clientId = call.argument<String>("client_id") ?: ""
val clientSecret = call.argument<String>("client_secret") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
}
result.success(null)
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
Gobackend.preWarmTrackCacheJSON(tracksJson)
}
result.success(null)
}
"getTrackCacheSize" -> {
val size = withContext(Dispatchers.IO) {
Gobackend.getTrackCacheSize()
}
result.success(size.toInt())
}
"clearTrackCache" -> {
withContext(Dispatchers.IO) {
Gobackend.clearTrackIDCache()
}
result.success(null)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
@@ -206,5 +237,37 @@ class MainActivity: FlutterActivity() {
}
}
}
// FFmpeg method channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
"execute" -> {
val command = call.argument<String>("command") ?: ""
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute(command)
}
val returnCode = session.returnCode
val output = session.output ?: ""
result.success(mapOf(
"success" to ReturnCode.isSuccess(returnCode),
"returnCode" to (returnCode?.value ?: -1),
"output" to output
))
}
"getVersion" -> {
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute("-version")
}
result.success(session.output ?: "unknown")
}
else -> result.notImplemented()
}
} catch (e: Exception) {
result.error("FFMPEG_ERROR", e.message, null)
}
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

+136
View File
@@ -0,0 +1,136 @@
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 iOS using ffmpeg_kit_flutter plugin
class FFmpegServiceIOS {
/// Execute FFmpeg command and return result
static Future<FFmpegResultIOS> _execute(String command) async {
try {
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final output = await session.getOutput() ?? '';
return FFmpegResultIOS(
success: ReturnCode.isSuccess(returnCode),
returnCode: returnCode?.getValue() ?? -1,
output: output,
);
} catch (e) {
_log.e('FFmpeg execute error: $e');
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
}
}
/// Convert M4A (DASH segments) to FLAC
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac');
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
try {
await File(inputPath).delete();
} catch (_) {}
return outputPath;
}
_log.e('M4A to FLAC conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to MP3
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command);
if (result.success) return outputPath;
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to M4A
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}M4A';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
String command;
if (codec == 'alac') {
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
} else {
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) return outputPath;
_log.e('FLAC to M4A conversion failed: ${result.output}');
return null;
}
/// Embed cover art to FLAC file
static Future<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
final result = await _execute(command);
if (result.success) {
try {
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
} catch (e) {
_log.e('Failed to replace file after cover embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) await tempFile.delete();
} catch (_) {}
_log.e('Cover embed failed: ${result.output}');
return null;
}
/// Check if FFmpeg is available
static Future<bool> isAvailable() async {
try {
final session = await FFmpegKit.execute('-version');
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode);
} catch (e) {
return false;
}
}
/// Get FFmpeg version info
static Future<String?> getVersion() async {
try {
final session = await FFmpegKit.execute('-version');
return await session.getOutput();
} catch (e) {
return null;
}
}
}
class FFmpegResultIOS {
final bool success;
final int returnCode;
final String output;
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
}
+155 -55
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
@@ -10,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -19,6 +21,12 @@ type AmazonDownloader struct {
regions []string // us, eu regions for DoubleDouble service
}
var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
type DoubleDoubleSubmitResponse struct {
Success bool `json:"success"`
@@ -36,12 +44,72 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"`
}
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
// amazonArtistsMatch checks if the artist names are similar enough
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
}
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
}
})
return globalAmazonDownloader
}
// GetAvailableAPIs returns list of available DoubleDouble regions
@@ -237,14 +305,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
}
defer out.Close()
// Use item progress writer
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
var bytesWritten int64
if itemID != "" {
pw := NewItemProgressWriter(out, itemID)
pw := NewItemProgressWriter(bufWriter, itemID)
bytesWritten, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
bytesWritten, err = io.Copy(out, resp.Body)
bytesWritten, err = io.Copy(bufWriter, resp.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
@@ -254,40 +326,56 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return nil
}
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
}
// downloadFromAmazon downloads a track using the request parameters
// Uses DoubleDouble service (same as PC version)
func downloadFromAmazon(req DownloadRequest) (string, error) {
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return "EXISTS:" + existingFile, nil
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Get Amazon URL from SongLink
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if err != nil {
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
// Create output directory if needed
if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
}
}
// Download using DoubleDouble service (same as PC)
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Verify artist matches
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
@@ -302,14 +390,32 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return "EXISTS:" + outputPath, nil
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// Download file with item ID for progress tracking
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -335,54 +441,48 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Amazon] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Amazon] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Amazon] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
// Convert Japanese lyrics to romaji if enabled
if req.ConvertLyricsToRomaji {
for i := range lyrics.Lines {
if ContainsKana(lyrics.Lines[i].Words) {
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
}
}
fmt.Println("[Amazon] Converted Japanese lyrics to romaji")
}
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
}
fmt.Println("[Amazon] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
fmt.Println("[Amazon] No lyrics available from parallel fetch")
}
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
return outputPath, nil
// Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath)
if err != nil {
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
// Return 0 to indicate unknown quality
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: 0,
SampleRate: 0,
}, nil
}
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: quality.BitDepth,
SampleRate: quality.SampleRate,
}, nil
}
+199 -33
View File
@@ -5,6 +5,7 @@ package gobackend
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
@@ -30,6 +31,12 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil
}
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
// Pass empty strings to use default credentials
func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret)
}
// GetSpotifyMetadata fetches metadata from Spotify URL
// Returns JSON with track/album/playlist data
func GetSpotifyMetadata(spotifyURL string) (string, error) {
@@ -122,12 +129,12 @@ type DownloadRequest struct {
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
ConvertLyricsToRomaji bool `json:"convert_lyrics_to_romaji"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking
ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
}
// DownloadResponse represents the result of a download
@@ -137,6 +144,17 @@ type DownloadResponse struct {
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
// Actual quality info from the source
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
}
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
}
// DownloadTrack downloads a track from the specified service
@@ -155,16 +173,40 @@ func DownloadTrack(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
var filePath string
var result DownloadResult
var err error
switch req.Service {
case "tidal":
filePath, err = downloadFromTidal(req)
tidalResult, tidalErr := downloadFromTidal(req)
if tidalErr == nil {
result = DownloadResult{
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
SampleRate: tidalResult.SampleRate,
}
}
err = tidalErr
case "qobuz":
filePath, err = downloadFromQobuz(req)
qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil {
result = DownloadResult{
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate,
}
}
err = qobuzErr
case "amazon":
filePath, err = downloadFromAmazon(req)
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
}
}
err = amazonErr
default:
return errorResponse("Unknown service: " + req.Service)
}
@@ -174,21 +216,44 @@ func DownloadTrack(requestJSON string) (string, error) {
}
// Check if file already exists
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{
Success: true,
Message: "File already exists",
FilePath: filePath[7:],
AlreadyExists: true,
Success: true,
Message: "File already exists",
FilePath: actualPath,
AlreadyExists: true,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{
Success: true,
Message: "Download complete",
FilePath: filePath,
Success: true,
Message: "Download complete",
FilePath: result.FilePath,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
}
jsonBytes, _ := json.Marshal(resp)
@@ -230,35 +295,82 @@ func DownloadWithFallback(requestJSON string) (string, error) {
for _, service := range services {
req.Service = service
var filePath string
var result DownloadResult
var err error
switch service {
case "tidal":
filePath, err = downloadFromTidal(req)
tidalResult, tidalErr := downloadFromTidal(req)
if tidalErr == nil {
result = DownloadResult{
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
SampleRate: tidalResult.SampleRate,
}
}
err = tidalErr
case "qobuz":
filePath, err = downloadFromQobuz(req)
qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil {
result = DownloadResult{
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate,
}
}
err = qobuzErr
case "amazon":
filePath, err = downloadFromAmazon(req)
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
}
}
err = amazonErr
}
if err == nil {
// Check if file already exists
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{
Success: true,
Message: "File already exists",
FilePath: filePath[7:],
AlreadyExists: true,
Success: true,
Message: "File already exists",
FilePath: actualPath,
AlreadyExists: true,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: service,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from " + service,
FilePath: filePath,
Success: true,
Message: "Downloaded from " + service,
FilePath: result.FilePath,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: service,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
@@ -367,14 +479,24 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
}
// GetLyricsLRC fetches lyrics and converts to LRC format string
func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) {
// First tries to extract from file, then falls back to fetching from internet
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// Try to extract from file first (much faster)
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
}
// Fallback to fetching from internet
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
if err != nil {
return "", err
}
lrcContent := convertToLRC(lyrics)
lrcContent := convertToLRC(lyricsData)
return lrcContent, nil
}
@@ -394,10 +516,54 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil
}
// ConvertToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
// Kanji characters are preserved as-is
func ConvertToRomaji(text string) string {
return ToRomaji(text)
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
// This runs in background and returns immediately
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
SpotifyID string `json:"spotify_id"`
Service string `json:"service"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return errorResponse("Invalid JSON: " + err.Error())
}
// Convert to PreWarmCacheRequest
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
SpotifyID: t.SpotifyID,
Service: t.Service,
}
}
// Run in background
go PreWarmTrackCache(requests)
resp := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)),
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// GetTrackCacheSize returns the current track ID cache size
func GetTrackCacheSize() int {
return GetCacheSize()
}
// ClearTrackIDCache clears the track ID cache
func ClearTrackIDCache() {
ClearTrackCache()
}
func errorResponse(msg string) (string, error) {
+4
View File
@@ -43,6 +43,7 @@ const (
)
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -56,6 +57,9 @@ var sharedTransport = &http.Transport{
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
}
// Shared HTTP client for general requests (reuses connections)
+99 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
return
}
// Remove existing
// Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- {
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
comment := cmt.Comments[i]
eqIdx := strings.Index(comment, "=")
if eqIdx > 0 {
existingKey := strings.ToUpper(comment[:eqIdx])
if existingKey == keyUpper {
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
}
}
}
// Add new
@@ -335,3 +342,92 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath)
}
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
// Try LYRICS tag first
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
// Fallback to UNSYNCEDLYRICS
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
}
}
return "", fmt.Errorf("no lyrics found in file")
}
// AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
}
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Read FLAC marker (4 bytes: "fLaC")
marker := make([]byte, 4)
if _, err := file.Read(marker); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
}
if string(marker) != "fLaC" {
return AudioQuality{}, fmt.Errorf("not a FLAC file")
}
// Read metadata block header (4 bytes)
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
// Bytes 1-3: block length (24-bit big-endian)
header := make([]byte, 4)
if _, err := file.Read(header); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
}
blockType := header[0] & 0x7F
if blockType != 0 {
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
}
// Read STREAMINFO block (34 bytes minimum)
// Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits)
streamInfo := make([]byte, 34)
if _, err := file.Read(streamInfo); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
}
// Parse sample rate (20 bits starting at byte 10)
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
// Parse bits per sample (5 bits)
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
return AudioQuality{
BitDepth: bitsPerSample,
SampleRate: sampleRate,
}, nil
}
+288
View File
@@ -0,0 +1,288 @@
package gobackend
import (
"fmt"
"sync"
"time"
)
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
AmazonTrackID string
ExpiresAt time.Time
}
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
}
var (
globalTrackIDCache *TrackIDCache
trackIDCacheOnce sync.Once
)
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes
}
})
return globalTrackIDCache
}
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.cache[isrc]
if !exists || time.Now().After(entry.ExpiresAt) {
return nil
}
return entry
}
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.TidalTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.QobuzTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.AmazonTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// Clear removes all cached entries
func (c *TrackIDCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry)
}
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct {
CoverData []byte
LyricsData *LyricsResponse
LyricsLRC string
CoverErr error
LyricsErr error
}
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
spotifyID string,
trackName string,
artistName string,
embedLyrics bool,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
if err != nil {
result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else {
result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
}
}()
}
// Fetch lyrics in parallel
if embedLyrics {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
result.LyricsLRC = convertToLRC(lyrics)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
}
}()
}
wg.Wait()
return result
}
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct {
ISRC string
TrackName string
ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup)
Service string // "tidal", "qobuz", "amazon"
}
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
// This runs in background while user is viewing the track list
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 {
return
}
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
// Limit concurrent pre-warm requests
semaphore := make(chan struct{}, 3) // Max 3 concurrent
var wg sync.WaitGroup
for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
wg.Add(1)
go func(r PreWarmCacheRequest) {
defer wg.Done()
semaphore <- struct{}{} // Acquire
defer func() { <-semaphore }() // Release
switch r.Service {
case "tidal":
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz":
preWarmQobuzCache(r.ISRC)
case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID)
}
}(req)
}
wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
}
func preWarmTidalCache(isrc, trackName, artistName string) {
downloader := NewTidalDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
}
}
func preWarmQobuzCache(isrc string) {
downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
}
}
func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
}
// ========================================
// Exported Functions for Flutter
// ========================================
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
return nil
}
// ClearTrackCache clears the track ID cache
func ClearTrackCache() {
GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
}
// GetCacheSize returns the current cache size
func GetCacheSize() int {
return GetTrackIDCache().Size()
}
+13 -2
View File
@@ -195,28 +195,39 @@ func getDownloadDir() string {
}
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
// Uses buffered writing for better performance
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
buffer []byte
bufPos int
}
const progressWriterBufferSize = 256 * 1024 // 256KB buffer for faster writes
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
return &ItemProgressWriter{
writer: w,
itemID: itemID,
current: 0,
buffer: make([]byte, progressWriterBufferSize),
bufPos: 0,
}
}
// Write implements io.Writer
// Write implements io.Writer with buffering
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
if err != nil {
return n, err
}
pw.current += int64(n)
SetItemBytesReceived(pw.itemID, pw.current)
// Update progress less frequently (every 64KB) to reduce lock contention
if pw.current%(64*1024) == 0 || pw.current == 0 {
SetItemBytesReceived(pw.itemID, pw.current)
}
return n, nil
}
+302 -65
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
@@ -9,6 +10,8 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"sync"
)
// QobuzDownloader handles Qobuz downloads
@@ -18,6 +21,12 @@ type QobuzDownloader struct {
apiURL string
}
var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
)
// QobuzTrack represents a Qobuz track
type QobuzTrack struct {
ID int64 `json:"id"`
@@ -39,12 +48,72 @@ type QobuzTrack struct {
} `json:"performer"`
}
// NewQobuzDownloader creates a new Qobuz downloader
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
appID: "798273057",
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := qobuzIsASCIIString(expectedArtist)
foundASCII := qobuzIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
}
// qobuzIsASCIIString checks if a string contains only ASCII characters
func qobuzIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
appID: "798273057",
}
})
return globalQobuzDownloader
}
// GetAvailableAPIs returns list of available Qobuz APIs
@@ -112,8 +181,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Find ISRC matches
var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
}
}
if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance
if durationDiff <= 30 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration)
}
// No duration to verify, return first match
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
if len(result.Tracks.Items) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
}
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0)
}
// SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
// SearchTrackByMetadataWithDuration searches for a track with duration verification
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies
@@ -129,6 +286,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
queries = append(queries, trackName)
}
var allTracks []QobuzTrack
for _, query := range queries {
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
@@ -159,19 +318,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
resp.Body.Close()
if len(result.Tracks.Items) > 0 {
// Return first result with best quality
for i := range result.Tracks.Items {
track := &result.Tracks.Items[i]
allTracks = append(allTracks, result.Tracks.Items...)
}
}
if len(allTracks) == 0 {
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
}
// If duration verification is requested
if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack
for i := range allTracks {
track := &allTracks[i]
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= 30 {
durationMatches = append(durationMatches, track)
}
}
if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 {
return track, nil
}
}
// Return first result if no hi-res found
return &result.Tracks.Items[0], nil
return durationMatches[0], nil
}
// No duration match found
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
}
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
// No duration verification, return best quality
for i := range allTracks {
track := &allTracks[i]
if track.MaximumBitDepth >= 24 {
return track, nil
}
}
return &allTracks[0], nil
}
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
@@ -294,45 +484,90 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
defer out.Close()
// Use item progress writer
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
progressWriter := NewItemProgressWriter(bufWriter, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
_, err = io.Copy(bufWriter, resp.Body)
}
return err
}
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
}
// downloadFromQobuz downloads a track using the request parameters
func downloadFromQobuz(req DownloadRequest) (string, error) {
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return "EXISTS:" + existingFile, nil
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack
var err error
// Strategy 1: Search by ISRC
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
track, err = downloader.SearchTrackByISRC(req.ISRC)
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil {
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err)
track = nil
}
}
}
// Strategy 2: Search by metadata
// Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" {
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
}
// Strategy 2: Search by metadata with duration verification
if track == nil {
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
}
if track == nil {
errMsg := "could not find track on Qobuz"
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
if err != nil {
errMsg = err.Error()
}
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
}
// Log match found and cache the track ID
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
// Build filename
@@ -349,7 +584,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return "EXISTS:" + outputPath, nil
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// Map quality from Tidal format to Qobuz format
@@ -366,17 +601,40 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
}
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get actual quality from track metadata
actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Download file with item ID for progress tracking
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -384,7 +642,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
SetItemFinalizing(req.ItemID)
}
// Embed metadata
// Embed metadata using parallel-fetched cover data
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
@@ -397,53 +655,32 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Qobuz] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Qobuz] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Qobuz] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Qobuz] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Qobuz] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
// Convert Japanese lyrics to romaji if enabled
if req.ConvertLyricsToRomaji {
for i := range lyrics.Lines {
if ContainsKana(lyrics.Lines[i].Words) {
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
}
}
fmt.Println("[Qobuz] Converted Japanese lyrics to romaji")
}
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
}
return outputPath, nil
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
SampleRate: actualSampleRate,
}, nil
}
-276
View File
@@ -1,276 +0,0 @@
package gobackend
import (
"strings"
"unicode"
)
// Japanese character ranges
const (
hiraganaStart = 0x3040
hiraganaEnd = 0x309F
katakanaStart = 0x30A0
katakanaEnd = 0x30FF
kanjiStart = 0x4E00
kanjiEnd = 0x9FFF
)
// hiraganaToRomaji maps hiragana characters to romaji
var hiraganaToRomaji = map[rune]string{
// Basic vowels
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
// K-row
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
// S-row
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
// T-row
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
// N-row
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
// H-row
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
// M-row
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
// Y-row
'や': "ya", 'ゆ': "yu", 'よ': "yo",
// R-row
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
// W-row
'わ': "wa", 'を': "wo",
// N
'ん': "n",
// Voiced (dakuten) - G-row
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
// Z-row
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
// D-row
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
// B-row
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// P-row (handakuten)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
'っ': "", // Small tsu - handled specially
// Long vowel mark
'ー': "",
}
// katakanaToRomaji maps katakana characters to romaji
var katakanaToRomaji = map[rune]string{
// Basic vowels
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
// K-row
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
// S-row
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
// T-row
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
// N-row
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", '': "no",
// H-row
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
// M-row
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
// Y-row
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
// R-row
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
// W-row
'ワ': "wa", 'ヲ': "wo",
// N
'ン': "n",
// Voiced (dakuten) - G-row
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
// Z-row
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
// D-row
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
// B-row
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// P-row (handakuten)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
'ッ': "", // Small tsu - handled specially
// Extended katakana
'ヴ': "vu",
// Long vowel mark
'ー': "",
}
// Extended katakana combinations (multi-character)
var katakanaExtended = map[string]string{
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
}
// Combination mappings for small ya/yu/yo
var hiraganaCombo = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
}
var katakanaCombo = map[string]string{
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
"シャ": "sha", "シュ": "shu", "ショ": "sho",
"チャ": "cha", "チュ": "chu", "チョ": "cho",
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
// Extended katakana combinations
"ティ": "ti", "ディ": "di",
"トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
"ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo",
}
// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji)
func ContainsJapanese(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) {
return true
}
}
return false
}
// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji)
func ContainsKana(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) {
return true
}
}
return false
}
func isHiragana(r rune) bool {
return r >= hiraganaStart && r <= hiraganaEnd
}
func isKatakana(r rune) bool {
return r >= katakanaStart && r <= katakanaEnd
}
func isKanji(r rune) bool {
return r >= kanjiStart && r <= kanjiEnd
}
// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
// Kanji characters are preserved as-is since they require dictionary lookup
func ToRomaji(s string) string {
if !ContainsKana(s) {
return s
}
runes := []rune(s)
var result strings.Builder
result.Grow(len(s) * 2) // Romaji is typically longer
i := 0
for i < len(runes) {
r := runes[i]
// Check for two-character combinations first
if i+1 < len(runes) {
combo := string(runes[i : i+2])
if romaji, ok := hiraganaCombo[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
if romaji, ok := katakanaCombo[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
}
// Handle small tsu (っ/ッ) - doubles the next consonant
if r == 'っ' || r == 'ッ' {
if i+1 < len(runes) {
nextRune := runes[i+1]
var nextRomaji string
if romaji, ok := hiraganaToRomaji[nextRune]; ok {
nextRomaji = romaji
} else if romaji, ok := katakanaToRomaji[nextRune]; ok {
nextRomaji = romaji
}
if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0]) // Double the consonant
}
}
i++
continue
}
// Handle long vowel mark (ー)
if r == 'ー' {
// Extend the previous vowel
resultStr := result.String()
if len(resultStr) > 0 {
lastChar := resultStr[len(resultStr)-1]
if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' {
result.WriteByte(lastChar)
}
}
i++
continue
}
// Single character conversion
if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji)
i++
continue
}
if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji)
i++
continue
}
// Keep non-Japanese characters as-is
if unicode.IsSpace(r) {
result.WriteRune(' ')
} else {
result.WriteRune(r)
}
i++
}
return result.String()
}
// GetRomajiVariants returns search variants for Japanese text
// Returns the original string plus romaji version if applicable
func GetRomajiVariants(s string) []string {
variants := []string{s}
if ContainsKana(s) {
romaji := ToRomaji(s)
if romaji != s && strings.TrimSpace(romaji) != "" {
variants = append(variants, romaji)
}
}
return variants
}
+14 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"sync"
"time"
)
@@ -25,11 +26,20 @@ type TrackAvailability struct {
QobuzURL string `json:"qobuz_url,omitempty"`
}
// NewSongLinkClient creates a new SongLink client
var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
)
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
}
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
}
})
return globalSongLinkClient
}
// CheckTrackAvailability checks track availability on streaming platforms
+91 -6
View File
@@ -62,11 +62,32 @@ type SpotifyMetadataClient struct {
cacheMu sync.RWMutex
}
// NewSpotifyMetadataClient creates a new Spotify client
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
// Custom credentials storage (set from Flutter)
var (
customClientID string
customClientSecret string
credentialsMu sync.RWMutex
)
// Prefer environment variables for credentials (more secure), fall back to built-in
// SetSpotifyCredentials sets custom Spotify API credentials
// Pass empty strings to use default credentials
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
customClientID = clientID
customClientSecret = clientSecret
}
// getCredentials returns the current credentials (custom or default)
func getCredentials() (string, string) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret
}
// Fall back to default credentials
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
if clientID == "" {
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
@@ -80,6 +101,16 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
clientSecret = string(decoded)
}
}
return clientID, clientSecret
}
// NewSpotifyMetadataClient creates a new Spotify client
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
// Get credentials (custom or default)
clientID, clientSecret := getCredentials()
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
@@ -536,6 +567,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
// First request to get playlist info and first batch of tracks
var data struct {
Name string `json:"name"`
Images []image `json:"images"`
@@ -546,7 +578,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
Items []struct {
Track *trackFull `json:"track"`
} `json:"items"`
Total int `json:"total"`
Total int `json:"total"`
Next string `json:"next"`
} `json:"tracks"`
}
@@ -560,7 +593,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images)
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items {
if item.Track == nil {
continue
@@ -584,6 +620,55 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
})
}
// Fetch remaining tracks using pagination (up to 1000 tracks max)
nextURL := data.Tracks.Next
maxTracks := 1000
for nextURL != "" && len(tracks) < maxTracks {
var pageData struct {
Items []struct {
Track *trackFull `json:"track"`
} `json:"items"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break
}
for _, item := range pageData.Items {
if item.Track == nil {
continue
}
if len(tracks) >= maxTracks {
break
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.Track.ID,
Artists: joinArtists(item.Track.Artists),
Name: item.Track.Name,
AlbumName: item.Track.Album.Name,
AlbumArtist: joinArtists(item.Track.Album.Artists),
DurationMS: item.Track.DurationMS,
Images: firstImageURL(item.Track.Album.Images),
ReleaseDate: item.Track.Album.ReleaseDate,
TrackNumber: item.Track.TrackNumber,
TotalTracks: item.Track.Album.TotalTracks,
DiscNumber: item.Track.DiscNumber,
ExternalURL: item.Track.ExternalURL.Spotify,
ISRC: item.Track.ExternalID.ISRC,
AlbumID: item.Track.Album.ID,
AlbumURL: item.Track.Album.ExternalURL.Spotify,
})
}
nextURL = pageData.Next
}
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
return &PlaylistResponsePayload{
PlaylistInfo: info,
TrackList: tracks,
+368 -121
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"encoding/xml"
@@ -12,17 +13,27 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// TidalDownloader handles Tidal downloads
type TidalDownloader struct {
client *http.Client
clientID string
clientSecret string
apiURL string
client *http.Client
clientID string
clientSecret string
apiURL string
cachedToken string
tokenExpiresAt time.Time
tokenMu sync.Mutex
}
var (
// Global Tidal downloader instance for token reuse
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
)
// TidalTrack represents a Tidal track
type TidalTrack struct {
ID int64 `json:"id"`
@@ -93,24 +104,25 @@ type MPD struct {
} `xml:"Period"`
}
// NewTidalDownloader creates a new Tidal downloader
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
func NewTidalDownloader() *TidalDownloader {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
tidalDownloaderOnce.Do(func() {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
downloader := &TidalDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
clientID: string(clientID),
clientSecret: string(clientSecret),
}
globalTidalDownloader = &TidalDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
clientID: string(clientID),
clientSecret: string(clientSecret),
}
// Get first available API
apis := downloader.GetAvailableAPIs()
if len(apis) > 0 {
downloader.apiURL = apis[0]
}
return downloader
// Get first available API
apis := globalTidalDownloader.GetAvailableAPIs()
if len(apis) > 0 {
globalTidalDownloader.apiURL = apis[0]
}
})
return globalTidalDownloader
}
// GetAvailableAPIs returns list of available Tidal APIs
@@ -138,8 +150,16 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
return apis
}
// GetAccessToken gets Tidal access token
// GetAccessToken gets Tidal access token (with caching)
func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock()
defer t.tokenMu.Unlock()
// Return cached token if still valid (with 60s buffer)
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
return t.cachedToken, nil
}
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
@@ -163,12 +183,21 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
// Cache the token
t.cachedToken = result.AccessToken
if result.ExpiresIn > 0 {
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
} else {
t.tokenExpiresAt = time.Now().Add(55 * time.Minute) // Default 55 min
}
return result.AccessToken, nil
}
@@ -315,6 +344,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
func normalizeTitle(title string) string {
normalized := strings.ToLower(strings.TrimSpace(title))
// Remove common suffixes in parentheses or brackets
suffixPatterns := []string{
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
" (bonus track)", " (single)", " (album version)", " (radio edit)",
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
}
for _, suffix := range suffixPatterns {
normalized = strings.TrimSuffix(normalized, suffix)
}
// Remove multiple spaces
for strings.Contains(normalized, " ") {
normalized = strings.ReplaceAll(normalized, " ", " ")
}
return normalized
}
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken()
@@ -335,33 +386,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
queries = append(queries, trackName)
}
// Strategy 3: Romaji versions if Japanese detected
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
// Try romaji version of track name
if ContainsKana(trackName) {
romajiTrack := ToRomaji(trackName)
if romajiTrack != trackName {
if artistName != "" {
queries = append(queries, artistName+" "+romajiTrack)
}
queries = append(queries, romajiTrack)
}
}
// Try romaji version of artist name
if ContainsKana(artistName) {
romajiArtist := ToRomaji(artistName)
if romajiArtist != artistName {
queries = append(queries, romajiArtist+" "+trackName)
// Try both romaji
if ContainsKana(trackName) {
romajiTrack := ToRomaji(trackName)
queries = append(queries, romajiArtist+" "+romajiTrack)
}
}
}
}
// Strategy 4: Artist only as last resort
// Strategy 3: Artist only as last resort
if artistName != "" {
queries = append(queries, artistName)
}
@@ -416,14 +441,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return nil, fmt.Errorf("no tracks found for any search query")
}
// Priority 1: Match by ISRC (exact match)
// Priority 1: Match by ISRC (exact match) WITH title verification
if spotifyISRC != "" {
var isrcMatches []*TidalTrack
for i := range allTracks {
track := &allTracks[i]
if track.ISRC == spotifyISRC {
return track, nil
isrcMatches = append(isrcMatches, track)
}
}
if len(isrcMatches) > 0 {
// Verify duration first (most important check)
if expectedDuration > 0 {
var durationVerifiedMatches []*TidalTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance for duration
if durationDiff <= 30 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
// Return first duration-verified match
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't - this is likely wrong version
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
expectedDuration, isrcMatches[0].Duration)
}
// No duration to verify, just return first ISRC match
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
// If ISRC was provided but no match found, return error
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
}
@@ -483,11 +544,18 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
}
// TidalDownloadInfo contains download URL and quality info
type TidalDownloadInfo struct {
URL string
BitDepth int
SampleRate int
}
// getDownloadURLSequential requests download URL from APIs sequentially
// Returns the first successful result (supports both v1 and v2 API formats)
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
}
client := NewHTTPClientWithTimeout(DefaultTimeout)
@@ -519,7 +587,12 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}
return apiURL, info, nil
}
// Fallback to v1 format (array with OriginalTrackUrl)
@@ -529,7 +602,13 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
return apiURL, item.OriginalTrackURL, nil
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
info := TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}
return apiURL, info, nil
}
}
}
@@ -537,22 +616,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
}
return "", "", fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries APIs sequentially
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
apis := t.GetAvailableAPIs()
if len(apis) == 0 {
return "", fmt.Errorf("no API URL configured")
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
}
_, downloadURL, err := getDownloadURLSequential(apis, trackID, quality)
_, info, err := getDownloadURLSequential(apis, trackID, quality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
}
return downloadURL, nil
return info, nil
}
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
@@ -678,13 +757,17 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
defer out.Close()
// Use item progress writer
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
progressWriter := NewItemProgressWriter(bufWriter, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
_, err = io.Copy(bufWriter, resp.Body)
}
return err
}
@@ -812,46 +895,210 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
return nil
}
// TidalDownloadResult contains download result with quality info
type TidalDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
}
// artistsMatch checks if the artist names are similar enough
func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
// Exact match
if normSpotify == normTidal {
return true
}
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
return true
}
// Check first artist (before comma or feat)
spotifyFirst := strings.Split(normSpotify, ",")[0]
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
spotifyFirst = strings.TrimSpace(spotifyFirst)
tidalFirst := strings.Split(normTidal, ",")[0]
tidalFirst = strings.Split(tidalFirst, " feat")[0]
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
tidalFirst = strings.TrimSpace(tidalFirst)
if spotifyFirst == tidalFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
spotifyASCII := isASCIIString(spotifyArtist)
tidalASCII := isASCIIString(tidalArtist)
if spotifyASCII != tidalASCII {
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true
}
return false
}
// isASCIIString checks if a string contains only ASCII characters
func isASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// downloadFromTidal downloads a track using the request parameters
func downloadFromTidal(req DownloadRequest) (string, error) {
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return "EXISTS:" + existingFile, nil
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *TidalTrack
var err error
// Strategy 1: Try to get Tidal URL from SongLink (using Spotify ID)
if req.SpotifyID != "" {
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
fmt.Printf("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
if err != nil {
fmt.Printf("[Tidal] Cache hit but failed to get track info: %v\n", err)
track = nil // Fall through to normal search
}
}
}
// OPTIMIZED: Try ISRC search first (faster than SongLink API)
// Strategy 1: Search by ISRC with duration verification (FASTEST)
if track == nil && req.ISRC != "" {
fmt.Printf("[Tidal] Trying ISRC search first (faster): %s\n", req.ISRC)
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
// Verify artist for ISRC match
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
}
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
if track == nil && req.SpotifyID != "" {
fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n")
tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID)
if slErr == nil && tidalURL != "" {
// Extract track ID and get track info
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
if idErr == nil {
track, err = downloader.GetTrackInfoByID(trackID)
if track != nil {
// Get artist name from track
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
// Verify artist matches
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
// Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance
if durationDiff > 30 {
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
}
}
}
}
}
// Strategy 2: Search by ISRC with multi-strategy fallback
if track == nil && req.ISRC != "" {
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0)
}
// Strategy 3: Search by metadata only (no ISRC requirement)
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
if track == nil {
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
fmt.Printf("[Tidal] Trying metadata search as last resort...\n")
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
// Verify artist for metadata search too
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
}
if track == nil {
errMsg := "could not find track on Tidal"
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
if err != nil {
errMsg = err.Error()
}
return "", fmt.Errorf("tidal search failed: %s", errMsg)
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
}
// Final verification logging
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
// Cache the track ID for future use
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
}
// Build filename
@@ -868,7 +1115,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return "EXISTS:" + outputPath, nil
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// Determine quality to use (default to LOSSLESS if not specified)
@@ -879,16 +1126,37 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
fmt.Printf("[Tidal] Using quality: %s\n", quality)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Download file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
// Log actual quality received
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -906,10 +1174,10 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil {
// Neither FLAC nor M4A exists
return "", fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
}
// Embed metadata
// Embed metadata using parallel-fetched cover data
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
@@ -922,17 +1190,11 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Tidal] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Tidal] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Tidal] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
// Only embed metadata to FLAC files (M4A will be converted by Flutter)
@@ -941,39 +1203,24 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Tidal] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Tidal] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Tidal] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
// Convert Japanese lyrics to romaji if enabled
if req.ConvertLyricsToRomaji {
for i := range lyrics.Lines {
if ContainsKana(lyrics.Lines[i].Words) {
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
}
}
fmt.Println("[Tidal] Converted Japanese lyrics to romaji")
}
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Tidal] Lyrics embedded successfully")
}
fmt.Println("[Tidal] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else {
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
}
return actualOutputPath, nil
return TidalDownloadResult{
FilePath: actualOutputPath,
BitDepth: downloadInfo.BitDepth,
SampleRate: downloadInfo.SampleRate,
}, nil
}
+2 -1
View File
@@ -164,7 +164,8 @@ import Gobackend // Import Go framework
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error)
let filePath = args["file_path"] as? String ?? ""
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
if let error = error { throw error }
return response
+4 -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 = '2.0.1';
static const String buildNumber = '31';
static const String version = '2.1.0';
static const String buildNumber = '41';
static const String fullVersion = '$version+$buildNumber';
@@ -15,4 +15,6 @@ class AppInfo {
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
}
+16 -4
View File
@@ -14,11 +14,14 @@ class AppSettings {
final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3
final bool checkForUpdates; // Check for updates on app start
final String updateChannel; // stable, preview
final bool hasSearchedBefore; // Hide helper text after first search
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
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
const AppSettings({
this.defaultService = 'tidal',
@@ -31,11 +34,14 @@ class AppSettings {
this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off)
this.checkForUpdates = true, // Default: enabled
this.updateChannel = 'stable', // Default: stable releases only
this.hasSearchedBefore = false, // Default: show helper text
this.folderOrganization = 'none', // Default: no folder organization
this.convertLyricsToRomaji = false, // Default: keep original Japanese
this.historyViewMode = 'grid', // Default: grid view
this.askQualityBeforeDownload = true, // Default: ask quality before download
this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials
this.useCustomSpotifyCredentials = true, // Default: use custom if set
});
AppSettings copyWith({
@@ -49,11 +55,14 @@ class AppSettings {
bool? isFirstLaunch,
int? concurrentDownloads,
bool? checkForUpdates,
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
bool? convertLyricsToRomaji,
String? historyViewMode,
bool? askQualityBeforeDownload,
String? spotifyClientId,
String? spotifyClientSecret,
bool? useCustomSpotifyCredentials,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -66,11 +75,14 @@ class AppSettings {
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji,
historyViewMode: historyViewMode ?? this.historyViewMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
);
}
+9 -2
View File
@@ -17,11 +17,15 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -36,9 +40,12 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
'historyViewMode': instance.historyViewMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
};
+10 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
const String kThemeModeKey = 'theme_mode';
const String kUseDynamicColorKey = 'use_dynamic_color';
const String kSeedColorKey = 'seed_color';
const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954;
@@ -13,11 +14,13 @@ class ThemeSettings {
final ThemeMode themeMode;
final bool useDynamicColor;
final int seedColorValue;
final bool useAmoled; // Pure black background for OLED screens
const ThemeSettings({
this.themeMode = ThemeMode.system,
this.useDynamicColor = true,
this.seedColorValue = kDefaultSeedColor,
this.useAmoled = false,
});
/// Get seed color as Color object
@@ -28,11 +31,13 @@ class ThemeSettings {
ThemeMode? themeMode,
bool? useDynamicColor,
int? seedColorValue,
bool? useAmoled,
}) {
return ThemeSettings(
themeMode: themeMode ?? this.themeMode,
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
seedColorValue: seedColorValue ?? this.seedColorValue,
useAmoled: useAmoled ?? this.useAmoled,
);
}
@@ -41,6 +46,7 @@ class ThemeSettings {
kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor,
kSeedColorKey: seedColorValue,
kUseAmoledKey: useAmoled,
};
/// Create from JSON map
@@ -49,6 +55,7 @@ class ThemeSettings {
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
useAmoled: json[kUseAmoledKey] as bool? ?? false,
);
}
@@ -58,12 +65,13 @@ class ThemeSettings {
return other is ThemeSettings &&
other.themeMode == themeMode &&
other.useDynamicColor == useDynamicColor &&
other.seedColorValue == seedColorValue;
other.seedColorValue == seedColorValue &&
other.useAmoled == useAmoled;
}
@override
int get hashCode =>
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
}
/// Helper to convert string to ThemeMode
+94 -29
View File
@@ -4,8 +4,6 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -688,20 +686,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Retry a failed download
/// Retry a failed or skipped download
void retryItem(String id) {
final items = state.items.map((item) {
if (item.id == id && item.status == DownloadStatus.failed) {
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
final item = state.items.where((i) => i.id == id).firstOrNull;
if (item == null) {
_log.w('retryItem: Item not found: $id');
return;
}
// Only retry if status is failed or skipped
if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) {
_log.w('retryItem: Item status is ${item.status}, not retrying');
return;
}
_log.i('Retrying item: ${item.track.name} (id: $id)');
final items = state.items.map((i) {
if (i.id == id) {
return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
}
return item;
return i;
}).toList();
state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
// Start processing if not already
// Start processing if not already running
if (!state.isProcessing) {
_log.d('Starting queue processing for retry');
Future.microtask(() => _processQueue());
} else {
_log.d('Queue already processing, item will be picked up');
}
}
@@ -745,25 +760,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
// FFmpeg can embed cover art to FLAC
if (coverPath != null && await File(coverPath).exists()) {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
final result = await FFmpegService.embedCover(flacPath, coverPath);
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
// Replace original with temp
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
if (result != null) {
_log.d('Cover embedded via FFmpeg');
} else {
// Try alternative method using metaflac-style embedding
_log.w('FFmpeg cover embed failed, trying alternative...');
// Clean up temp file if exists
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
_log.w('FFmpeg cover embed failed');
}
// Clean up cover file
@@ -866,6 +868,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
// Check if there are new queued items (e.g., from retry) and restart if needed
final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued);
if (hasQueuedItems) {
_log.i('Found queued items after processing finished, restarting queue...');
Future.microtask(() => _processQueue());
}
}
/// Sequential download processing (uses multi-progress system with single item)
@@ -881,7 +890,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
continue;
}
final nextItem = state.items.firstWhere(
// Re-read state to get latest items (important for retry)
final currentItems = state.items;
final nextItem = currentItems.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
id: '',
@@ -892,10 +903,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (nextItem.id.isEmpty) {
_log.d('No more items to process');
_log.d('No more items to process (checked ${currentItems.length} items)');
break;
}
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})');
await _downloadSingleItem(nextItem);
// Clear item progress after download completes
@@ -1007,7 +1019,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: item.track.releaseDate,
preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking
convertLyricsToRomaji: settings.convertLyricsToRomaji,
durationMs: item.track.duration, // Duration in ms for verification
);
} else {
result = await PlatformBridge.downloadTrack(
@@ -1026,16 +1038,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
itemId: item.id, // Pass item ID for progress tracking
convertLyricsToRomaji: settings.convertLyricsToRomaji,
durationMs: item.track.duration, // Duration in ms for verification
);
}
_log.d('Result: $result');
// Check if item was cancelled while downloading
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping result processing');
// Delete the downloaded file if it exists
final filePath = result['file_path'] as String?;
if (filePath != null && result['success'] == true) {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
_log.d('Deleted cancelled download file: $filePath');
}
} catch (e) {
_log.w('Failed to delete cancelled file: $e');
}
}
return;
}
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
_log.i('Download success, file: $filePath');
// Get actual quality from response (if available)
final actualBitDepth = result['actual_bit_depth'] as int?;
final actualSampleRate = result['actual_sample_rate'] as int?;
String actualQuality = quality; // Default to requested quality
if (actualBitDepth != null && actualBitDepth > 0) {
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1)
: '?';
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
_log.i('Actual quality: $actualQuality');
}
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
_log.d('Converting M4A to FLAC...');
@@ -1059,6 +1105,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
// Check again if cancelled before updating status and adding to history
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
if (itemAfterDownload.status == DownloadStatus.skipped) {
_log.i('Download was cancelled during finalization, cleaning up');
// Delete the downloaded file
if (filePath != null) {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
_log.d('Deleted cancelled download file: $filePath');
}
} catch (e) {
_log.w('Failed to delete cancelled file: $e');
}
}
return;
}
updateItemStatus(
item.id,
DownloadStatus.completed,
@@ -1096,7 +1161,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: item.track.discNumber,
duration: item.track.duration,
releaseDate: item.track.releaseDate,
quality: quality,
quality: actualQuality,
),
);
+58 -5
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
const _settingsKey = 'app_settings';
@@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
}
}
@@ -25,6 +28,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
}
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async {
// Only apply custom credentials if enabled and both fields are set
if (state.useCustomSpotifyCredentials &&
state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
state.spotifyClientSecret,
);
} else {
// Clear to use default
await PlatformBridge.setSpotifyCredentials('', '');
}
}
void setDefaultService(String service) {
state = state.copyWith(defaultService: service);
_saveSettings();
@@ -77,6 +96,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUpdateChannel(String channel) {
state = state.copyWith(updateChannel: channel);
_saveSettings();
}
void setHasSearchedBefore() {
if (!state.hasSearchedBefore) {
state = state.copyWith(hasSearchedBefore: true);
@@ -89,11 +113,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setConvertLyricsToRomaji(bool enabled) {
state = state.copyWith(convertLyricsToRomaji: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
@@ -103,6 +122,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings();
}
void setSpotifyClientId(String clientId) {
state = state.copyWith(spotifyClientId: clientId);
_saveSettings();
}
void setSpotifyClientSecret(String clientSecret) {
state = state.copyWith(spotifyClientSecret: clientSecret);
_saveSettings();
}
void setSpotifyCredentials(String clientId, String clientSecret) {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
_saveSettings();
_applySpotifyCredentials();
}
void clearSpotifyCredentials() {
state = state.copyWith(
spotifyClientId: '',
spotifyClientSecret: '',
);
_saveSettings();
_applySpotifyCredentials();
}
void setUseCustomSpotifyCredentials(bool enabled) {
state = state.copyWith(useCustomSpotifyCredentials: enabled);
_saveSettings();
_applySpotifyCredentials();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+9
View File
@@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey);
final useAmoled = prefs.getBool(kUseAmoledKey);
state = ThemeSettings(
themeMode: _themeModeFromString(modeString),
useDynamicColor: useDynamic ?? true,
seedColorValue: seedColor ?? kDefaultSeedColor,
useAmoled: useAmoled ?? false,
);
} catch (e) {
debugPrint('Error loading theme settings: $e');
@@ -43,6 +45,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue);
await prefs.setBool(kUseAmoledKey, state.useAmoled);
} catch (e) {
debugPrint('Error saving theme settings: $e');
}
@@ -72,6 +75,12 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage();
}
/// Enable or disable AMOLED mode (pure black background)
Future<void> setUseAmoled(bool value) async {
state = state.copyWith(useAmoled: value);
await _saveToStorage();
}
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
+37 -6
View File
@@ -118,7 +118,8 @@ class TrackNotifier extends Notifier<TrackState> {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
state = const TrackState(isLoading: true);
// Preserve hasSearchText during fetch
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
final parsed = await PlatformBridge.parseSpotifyUrl(url);
@@ -148,6 +149,8 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
// Pre-warm cache for album tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
@@ -159,6 +162,8 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
// Pre-warm cache for playlist tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
@@ -174,7 +179,8 @@ class TrackNotifier extends Notifier<TrackState> {
}
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString());
// Preserve hasSearchText on error so user stays on search screen
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
@@ -182,7 +188,8 @@ class TrackNotifier extends Notifier<TrackState> {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
state = const TrackState(isLoading: true);
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
@@ -198,10 +205,12 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: tracks,
searchArtists: artists,
isLoading: false,
hasSearchText: state.hasSearchText,
);
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString());
// Preserve hasSearchText on error so user stays on search screen
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
@@ -261,7 +270,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
@@ -277,7 +286,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
@@ -305,6 +314,28 @@ class TrackNotifier extends Notifier<TrackState> {
popularity: data['popularity'] as int? ?? 0,
);
}
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) {
// Only pre-warm if we have tracks with ISRC
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
// Build request list for Go backend
final cacheRequests = tracksWithIsrc.map((t) => {
'isrc': t.isrc!,
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal', // Default to tidal for pre-warming
}).toList();
// Fire and forget - runs in background
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
// Silently ignore errors - this is just an optimization
});
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+160 -27
View File
@@ -104,7 +104,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
@@ -126,10 +126,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme),
@@ -302,8 +302,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -317,8 +317,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (tracks == null || tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
} else {
@@ -327,32 +327,131 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
),
),
),
);
}
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
],
),
),
@@ -381,6 +480,40 @@ class _QualityOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+64 -1
View File
@@ -128,7 +128,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
@@ -318,4 +318,67 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
));
}
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
],
),
),
);
}
}
+201 -67
View File
@@ -22,7 +22,6 @@ class HomeTab extends ConsumerStatefulWidget {
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController();
Timer? _debounce;
bool _isTyping = false;
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
@@ -38,7 +37,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
@override
void dispose() {
_debounce?.cancel();
_urlController.removeListener(_onSearchChanged);
_urlController.dispose();
_searchFocusNode.dispose();
@@ -48,17 +46,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
/// 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
// BUT only if search field is not focused (to prevent clearing while user is typing)
if (previous != null &&
!next.hasContent &&
!next.hasSearchText &&
!next.isLoading &&
_urlController.text.isNotEmpty) {
_urlController.text.isNotEmpty &&
!_searchFocusNode.hasFocus) {
_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);
@@ -68,30 +67,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
setState(() => _isTyping = true);
} else if (text.isEmpty && _isTyping) {
setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear();
// Don't clear provider here - it causes focus issues
// Provider will be cleared when user explicitly clears or navigates away
return;
}
// Re-request focus after rebuild if it was focused
if (wasFocused) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_searchFocusNode.requestFocus();
}
});
}
// Debounce all requests (URLs and searches)
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
if (text.isEmpty) return;
if (text.startsWith('http') || text.startsWith('spotify:')) {
_fetchMetadata();
} else if (text.length >= 2) {
_performSearch(text);
}
});
// No auto-search - user must press Enter to search
// This saves API calls and avoids rate limiting
}
Future<void> _performSearch(String query) async {
@@ -116,7 +98,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
Future<void> _clearAndRefresh() async {
_debounce?.cancel();
_urlController.clear();
_searchFocusNode.unfocus();
_lastSearchQuery = null; // Reset last query
@@ -189,8 +170,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -200,48 +181,84 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityPickerOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
icon: Icons.music_note,
onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
icon: Icons.high_quality,
onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
icon: Icons.four_k,
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); },
),
const SizedBox(height: 16),
],
),
_QualityPickerOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
icon: Icons.music_note,
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
icon: Icons.high_quality,
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
icon: Icons.four_k,
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
),
const SizedBox(height: 16),
],
),
),
),
);
@@ -274,6 +291,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
return Scaffold(
body: CustomScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
// App Bar - always present
SliverAppBar(
@@ -468,6 +486,69 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
));
}
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment before searching again.',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
],
),
),
);
}
// Search results slivers - only shows search results (track list)
List<Widget> _buildSearchResults({
required List<Track> tracks,
@@ -482,11 +563,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
return [
// Error message
// Error message - with special handling for rate limit (429)
if (error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(error, style: TextStyle(color: colorScheme.error)),
child: _buildErrorWidget(error, colorScheme),
)),
// Loading indicator
@@ -663,10 +744,29 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
onSubmitted: (_) => _fetchMetadata(),
onSubmitted: (_) => _onSearchSubmitted(),
);
}
/// Handle Enter key press - search or fetch URL
void _onSearchSubmitted() {
final text = _urlController.text.trim();
if (text.isEmpty) return;
// If it's a URL, fetch metadata
if (text.startsWith('http') || text.startsWith('spotify:')) {
_fetchMetadata();
_searchFocusNode.unfocus();
return;
}
// For search queries, always search (minimum 2 chars)
if (text.length >= 2) {
_performSearch(text);
}
_searchFocusNode.unfocus();
}
}
class _QualityPickerOption extends StatelessWidget {
@@ -689,6 +789,40 @@ class _QualityPickerOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+14 -2
View File
@@ -85,7 +85,7 @@ class _MainShellState extends ConsumerState<MainShell> {
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
final updateInfo = await UpdateChecker.checkForUpdate();
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
if (updateInfo != null && mounted) {
showUpdateDialog(
context,
@@ -125,6 +125,13 @@ class _MainShellState extends ConsumerState<MainShell> {
void _handleBackPress() {
final trackState = ref.read(trackProvider);
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusScope.of(context).unfocus();
return;
}
// If on Home tab and has text in search bar or has content (but not loading), clear it
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
@@ -163,12 +170,17 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider);
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Determine if we can pop (for predictive back animation)
// canPop is true when we're at root with no content - enables predictive back gesture
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
final canPop = _currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading;
!trackState.isLoading &&
!isKeyboardVisible;
return PopScope(
canPop: canPop,
+93 -23
View File
@@ -168,8 +168,8 @@ class PlaylistScreen extends ConsumerWidget {
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -182,8 +182,8 @@ class PlaylistScreen extends ConsumerWidget {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: playlistName);
} else {
@@ -192,30 +192,66 @@ class PlaylistScreen extends ConsumerWidget {
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
),
),
),
);
@@ -243,6 +279,40 @@ class _QualityOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+57 -5
View File
@@ -558,6 +558,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
// Quality badge (top-left)
if (item.quality != null && item.quality!.contains('bit'))
Positioned(
left: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: item.quality!.startsWith('24')
? colorScheme.tertiary
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.quality!.split('/').first, // Just show "24-bit" or "16-bit"
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: item.quality!.startsWith('24')
? colorScheme.onTertiary
: colorScheme.onSurfaceVariant,
fontSize: 9,
fontWeight: FontWeight.w600,
),
),
),
),
// Play button overlay
if (fileExists)
Positioned(
@@ -677,11 +702,38 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
const SizedBox(height: 2),
Text(
dateStr,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
Row(
children: [
Text(
dateStr,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
// Quality badge
if (item.quality != null && item.quality!.contains('bit')) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: item.quality!.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.quality!,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: item.quality!.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
],
),
+56 -44
View File
@@ -12,53 +12,46 @@ class AboutPage extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
// When collapsed (expandRatio=0): left=56 to align with back button
// When expanded (expandRatio=1): left=24 for normal padding
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text(
'About',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
// When collapsed (expandRatio=0): left=56 to avoid back button
// When expanded (expandRatio=1): left=24 for normal padding
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'About',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},
);
},
),
),
),
// App header card with logo and description
SliverToBoxAdapter(
@@ -130,6 +123,24 @@ class AboutPage extends StatelessWidget {
),
),
// Support section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Support'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.coffee_outlined,
title: 'Buy me a coffee',
subtitle: 'Support development on Ko-fi',
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
],
),
),
// App info section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
@@ -166,6 +177,7 @@ class AboutPage extends StatelessWidget {
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
);
}
@@ -14,104 +14,121 @@ class AppearanceSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('Appearance',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
),
// Theme section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background for OLED screens',
value: themeSettings.useAmoled,
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
showDivider: false,
),
],
),
),
// Color section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
SliverToBoxAdapter(
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),
),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
);
},
],
),
),
),
// Theme section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
],
),
),
// Color section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
SliverToBoxAdapter(
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),
),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
],
),
),
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
),
),
);
}
}
/// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget {
final String title;
final double topPadding;
const _AppBarTitle({required this.title, required this.topPadding});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
title,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
);
}
}
class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged;
+123 -43
View File
@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -13,47 +15,41 @@ class DownloadSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('Download',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Download',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},
);
},
),
),
),
// Service section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
@@ -119,8 +115,10 @@ class DownloadSettingsPage extends ConsumerWidget {
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
onTap: () => _pickDirectory(ref),
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsItem(
icon: Icons.create_new_folder_outlined,
@@ -136,6 +134,7 @@ class DownloadSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -166,9 +165,90 @@ class DownloadSettingsPage extends ConsumerWidget {
);
}
Future<void> _pickDirectory(WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
if (Platform.isIOS) {
// iOS: Show options dialog
_showIOSDirectoryOptions(context, ref);
} else {
// Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await getApplicationDocumentsDirectory();
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
],
),
),
),
const SizedBox(height: 8),
],
),
),
);
}
String _getFolderOrganizationLabel(String value) {
+269 -56
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/settings.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';
@@ -13,47 +14,41 @@ class OptionsSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
),
child: Text('Options',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Options',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},
);
},
),
),
),
// Download options section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
@@ -99,23 +94,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Lyrics section
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
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
SliverToBoxAdapter(
@@ -127,8 +105,43 @@ class OptionsSettingsPage extends ConsumerWidget {
subtitle: 'Notify when new version is available',
value: settings.checkForUpdates,
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
showDivider: false,
),
_UpdateChannelSelector(
currentChannel: settings.updateChannel,
onChanged: (v) => ref.read(settingsProvider.notifier).setUpdateChannel(v),
),
],
),
),
// Spotify API section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Spotify API')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.key,
title: 'Custom Credentials',
subtitle: settings.spotifyClientId.isNotEmpty
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
: 'Not configured',
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
trailing: settings.spotifyClientId.isNotEmpty
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
showDivider: settings.spotifyClientId.isNotEmpty,
),
if (settings.spotifyClientId.isNotEmpty)
SettingsSwitchItem(
icon: Icons.toggle_on,
title: 'Use Custom Credentials',
subtitle: settings.useCustomSpotifyCredentials
? 'Using your credentials'
: 'Using default credentials',
value: settings.useCustomSpotifyCredentials,
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
showDivider: false,
),
],
),
),
@@ -152,6 +165,7 @@ class OptionsSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -180,6 +194,132 @@ class OptionsSettingsPage extends ConsumerWidget {
),
);
}
void _showSpotifyCredentialsDialog(BuildContext context, WidgetRef ref, AppSettings settings) {
final clientIdController = TextEditingController(text: settings.spotifyClientId);
final clientSecretController = TextEditingController(text: settings.spotifyClientSecret);
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Text('Spotify API Credentials', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Use your own credentials to avoid rate limiting.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: TextField(
controller: clientIdController,
decoration: InputDecoration(
labelText: 'Client ID',
hintText: 'Enter Spotify Client ID',
filled: true,
fillColor: colorScheme.surfaceContainerLow,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: TextField(
controller: clientSecretController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Client Secret',
hintText: 'Enter Spotify Client Secret',
filled: true,
fillColor: colorScheme.surfaceContainerLow,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
children: [
if (settings.spotifyClientId.isNotEmpty)
Expanded(
child: OutlinedButton(
onPressed: () {
ref.read(settingsProvider.notifier).clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials cleared')),
);
},
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
minimumSize: const Size.fromHeight(52),
),
child: const Text('Clear'),
),
),
if (settings.spotifyClientId.isNotEmpty) const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
final clientId = clientIdController.text.trim();
final clientSecret = clientSecretController.text.trim();
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
ref.read(settingsProvider.notifier).setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials saved')),
);
} else if (clientId.isEmpty && clientSecret.isEmpty) {
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please fill both Client ID and Secret')),
);
}
},
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
minimumSize: const Size.fromHeight(52),
),
child: const Text('Save'),
),
),
],
),
),
],
),
),
),
),
);
}
}
class _ConcurrentDownloadsItem extends StatelessWidget {
@@ -256,3 +396,76 @@ class _ConcurrentChip extends StatelessWidget {
);
}
}
class _UpdateChannelSelector extends StatelessWidget {
final String currentChannel;
final ValueChanged<String> onChanged;
const _UpdateChannelSelector({required this.currentChannel, required this.onChanged});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.new_releases, color: colorScheme.onSurfaceVariant, size: 24),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Update Channel', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(currentChannel == 'preview' ? 'Get preview releases' : 'Stable releases only',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
]),
const SizedBox(height: 16),
Row(children: [
_ChannelChip(label: 'Stable', isSelected: currentChannel == 'stable', onTap: () => onChanged('stable')),
const SizedBox(width: 8),
_ChannelChip(label: 'Preview', isSelected: currentChannel == 'preview', onTap: () => onChanged('preview')),
]),
const SizedBox(height: 12),
Row(children: [
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(child: Text('Preview may contain bugs or incomplete features',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
]),
]),
);
}
}
class _ChannelChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ChannelChip({required this.label, required this.isSelected, required this.onTap});
@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 : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
),
),
),
);
}
}
+12
View File
@@ -392,7 +392,19 @@ class SettingsScreen extends ConsumerWidget {
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
],
+12
View File
@@ -389,7 +389,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
+137 -22
View File
@@ -87,10 +87,43 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
PermissionStatus status;
if (_androidSdkVersion >= 33) {
// Android 13+: Use audio permission
status = await Permission.audio.request();
} else if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE
// This opens system settings, not a dialog
status = await Permission.manageExternalStorage.status;
if (!status.isGranted) {
// Show explanation dialog first
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Access Required'),
content: const Text(
'Android 11+ requires "All files access" permission to save music files.\n\n'
'Please enable "Allow access to manage all files" in the next screen.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Open Settings'),
),
],
),
);
if (shouldOpen == true) {
status = await Permission.manageExternalStorage.request();
}
}
}
} else {
// Android 10 and below: Use legacy storage permission
status = await Permission.storage.request();
}
@@ -172,29 +205,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
setState(() => _isLoading = true);
try {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
);
if (selectedDirectory != null) {
setState(() => _selectedDirectory = selectedDirectory);
if (Platform.isIOS) {
// iOS: Show options dialog
await _showIOSDirectoryOptions();
} else {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
// Android: Use file picker
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
if (selectedDirectory != null) {
setState(() => _selectedDirectory = selectedDirectory);
} else {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
}
}
}
}
@@ -203,6 +242,82 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
}
Future<void> _showIOSDirectoryOptions() async {
final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await _getDefaultDirectory();
setState(() => _selectedDirectory = dir);
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
setState(() => _selectedDirectory = result);
}
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
],
),
),
),
const SizedBox(height: 8),
],
),
),
);
}
Future<String> _getDefaultDirectory() async {
if (Platform.isIOS) {
final appDir = await getApplicationDocumentsDirectory();
+45 -14
View File
@@ -47,6 +47,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_fileExists = exists;
_fileSize = size;
});
// Auto-load lyrics if file exists (embedded lyrics are instant)
if (exists) {
_fetchLyrics();
}
}
}
@@ -359,22 +364,38 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Future<void> _openSpotifyUrl(BuildContext context) async {
if (item.spotifyId == null) return;
final url = 'https://open.spotify.com/track/${item.spotifyId}';
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
try {
// Try to open in Spotify app first, fallback to browser
final uri = Uri.parse('spotify:track:${item.spotifyId}');
// ignore: deprecated_member_use
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
// Try to open in Spotify app first using URI scheme
final launched = await launchUrl(
spotifyUri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
// Fallback to web URL which will redirect to app if installed
await launchUrl(
Uri.parse(webUrl),
mode: LaunchMode.externalApplication,
);
}
} catch (e) {
if (context.mounted) {
_copyToClipboard(context, url);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify URL copied to clipboard')),
// If URI scheme fails, try web URL
try {
await launchUrl(
Uri.parse(webUrl),
mode: LaunchMode.externalApplication,
);
} catch (_) {
// Last resort: copy to clipboard
if (context.mounted) {
_copyToClipboard(context, webUrl);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify URL copied to clipboard')),
);
}
}
}
}
@@ -392,6 +413,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataItem('Disc number', item.discNumber.toString()),
if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)),
if (item.quality != null && item.quality!.contains('bit'))
_MetadataItem('Audio quality', item.quality!),
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
_MetadataItem('Release date', item.releaseDate!),
if (item.isrc != null && item.isrc!.isNotEmpty)
@@ -736,16 +759,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
try {
// Add timeout to prevent infinite loading
final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '',
item.trackName,
item.artistName,
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
).timeout(
const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout
);
if (mounted) {
if (result.isEmpty) {
setState(() {
_lyricsError = 'Lyrics not found';
_lyricsError = 'Lyrics not available for this track';
_lyricsLoading = false;
});
} else {
@@ -759,8 +787,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
} catch (e) {
if (mounted) {
final errorMsg = e.toString().contains('TimeoutException')
? 'Request timed out. Try again later.'
: 'Failed to load lyrics';
setState(() {
_lyricsError = 'Failed to load lyrics';
_lyricsError = errorMsg;
_lyricsLoading = false;
});
}
+78 -23
View File
@@ -1,12 +1,30 @@
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:flutter/services.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
/// FFmpeg service for audio conversion and remuxing
/// Uses native MethodChannel to call FFmpegKit from local AAR
class FFmpegService {
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
/// Execute FFmpeg command and return result
static Future<FFmpegResult> _execute(String command) async {
try {
final result = await _channel.invokeMethod('execute', {'command': command});
final map = Map<String, dynamic>.from(result);
return FFmpegResult(
success: map['success'] as bool,
returnCode: map['returnCode'] as int,
output: map['output'] as String,
);
} catch (e) {
_log.e('FFmpeg execute error: $e');
return FFmpegResult(success: false, returnCode: -1, output: e.toString());
}
}
/// Convert M4A (DASH segments) to FLAC
/// Returns the output file path on success, null on failure
static Future<String?> convertM4aToFlac(String inputPath) async {
@@ -16,10 +34,9 @@ class FFmpegService {
final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final result = await _execute(command);
if (ReturnCode.isSuccess(returnCode)) {
if (result.success) {
// Delete original M4A file
try {
await File(inputPath).delete();
@@ -27,12 +44,7 @@ class FFmpegService {
return outputPath;
}
// Log error for debugging
final logs = await session.getLogs();
for (final log in logs) {
_log.d(log.getMessage());
}
_log.e('M4A to FLAC conversion failed: ${result.output}');
return null;
}
@@ -54,13 +66,13 @@ class FFmpegService {
final command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final result = await _execute(command);
if (ReturnCode.isSuccess(returnCode)) {
if (result.success) {
return outputPath;
}
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null;
}
@@ -91,22 +103,21 @@ class FFmpegService {
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final result = await _execute(command);
if (ReturnCode.isSuccess(returnCode)) {
if (result.success) {
return outputPath;
}
_log.e('FLAC to M4A conversion failed: ${result.output}');
return null;
}
/// Check if FFmpeg is available
static Future<bool> isAvailable() async {
try {
final session = await FFmpegKit.execute('-version');
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode);
final version = await _channel.invokeMethod('getVersion');
return version != null && version.toString().isNotEmpty;
} catch (e) {
return false;
}
@@ -115,11 +126,55 @@ class FFmpegService {
/// Get FFmpeg version info
static Future<String?> getVersion() async {
try {
final session = await FFmpegKit.execute('-version');
final output = await session.getOutput();
return output;
final version = await _channel.invokeMethod('getVersion');
return version as String?;
} catch (e) {
return null;
}
}
/// Embed cover art to FLAC file
/// Returns the file path on success, null on failure
static Future<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
final result = await _execute(command);
if (result.success) {
try {
// Replace original with temp
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
} catch (e) {
_log.e('Failed to replace file after cover embed: $e');
return null;
}
}
// Clean up temp file if exists
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('Cover embed failed: ${result.output}');
return null;
}
}
/// Result of FFmpeg command execution
class FFmpegResult {
final bool success;
final int returnCode;
final String output;
FFmpegResult({
required this.success,
required this.returnCode,
required this.output,
});
}
+37 -6
View File
@@ -60,12 +60,12 @@ class PlatformBridge {
String quality = 'LOSSLESS',
bool embedLyrics = true,
bool embedMaxQualityCover = true,
bool convertLyricsToRomaji = false,
int trackNumber = 1,
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String? itemId,
int durationMs = 0,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -81,12 +81,12 @@ class PlatformBridge {
'quality': quality,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'convert_lyrics_to_romaji': convertLyricsToRomaji,
'track_number': trackNumber,
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
});
final result = await _channel.invokeMethod('downloadTrack', request);
@@ -107,13 +107,13 @@ class PlatformBridge {
String quality = 'LOSSLESS',
bool embedLyrics = true,
bool embedMaxQualityCover = true,
bool convertLyricsToRomaji = false,
int trackNumber = 1,
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String preferredService = 'tidal',
String? itemId,
int durationMs = 0,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -129,12 +129,12 @@ class PlatformBridge {
'quality': quality,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'convert_lyrics_to_romaji': convertLyricsToRomaji,
'track_number': trackNumber,
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
});
final result = await _channel.invokeMethod('downloadWithFallback', request);
@@ -214,15 +214,18 @@ class PlatformBridge {
}
/// Get lyrics in LRC format
/// First tries to extract from embedded file, then falls back to internet
static Future<String> getLyricsLRC(
String spotifyId,
String trackName,
String artistName,
) async {
String artistName, {
String? filePath,
}) async {
final result = await _channel.invokeMethod('getLyricsLRC', {
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'file_path': filePath ?? '',
});
return result as String;
}
@@ -285,4 +288,32 @@ class PlatformBridge {
final result = await _channel.invokeMethod('isDownloadServiceRunning');
return result as bool;
}
/// Set custom Spotify API credentials
/// Pass empty strings to use default credentials
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
await _channel.invokeMethod('setSpotifyCredentials', {
'client_id': clientId,
'client_secret': clientSecret,
});
}
/// Pre-warm track ID cache for album/playlist tracks
/// This runs in background and returns immediately
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
final tracksJson = jsonEncode(tracks);
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
}
/// Get current track cache size
static Future<int> getTrackCacheSize() async {
final result = await _channel.invokeMethod('getTrackCacheSize');
return result as int;
}
/// Clear track ID cache
static Future<void> clearTrackCache() async {
await _channel.invokeMethod('clearTrackCache');
}
}
+50 -17
View File
@@ -12,6 +12,7 @@ class UpdateInfo {
final String downloadUrl;
final String? apkDownloadUrl;
final DateTime publishedAt;
final bool isPrerelease;
const UpdateInfo({
required this.version,
@@ -19,11 +20,13 @@ class UpdateInfo {
required this.downloadUrl,
this.apkDownloadUrl,
required this.publishedAt,
this.isPrerelease = false,
});
}
class UpdateChecker {
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
static Future<String> _getDeviceArch() async {
if (!Platform.isAndroid) return 'unknown';
@@ -55,30 +58,59 @@ class UpdateChecker {
}
}
static Future<UpdateInfo?> checkForUpdate() async {
/// Check for updates based on channel preference
/// [channel] can be 'stable' or 'preview'
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
try {
final response = await http.get(
Uri.parse(_apiUrl),
headers: {'Accept': 'application/vnd.github.v3+json'},
).timeout(const Duration(seconds: 10));
Map<String, dynamic>? releaseData;
if (channel == 'preview') {
// For preview channel, get all releases and find the latest (including prereleases)
final response = await http.get(
Uri.parse('$_allReleasesApiUrl?per_page=10'),
headers: {'Accept': 'application/vnd.github.v3+json'},
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
return null;
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
return null;
}
final releases = jsonDecode(response.body) as List<dynamic>;
if (releases.isEmpty) {
_log.i('No releases found');
return null;
}
// First release is the latest (including prereleases)
releaseData = releases.first as Map<String, dynamic>;
} else {
// For stable channel, use /latest endpoint (excludes prereleases)
final response = await http.get(
Uri.parse(_latestApiUrl),
headers: {'Accept': 'application/vnd.github.v3+json'},
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
return null;
}
releaseData = jsonDecode(response.body) as Map<String, dynamic>;
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final tagName = data['tag_name'] as String? ?? '';
final tagName = releaseData['tag_name'] as String? ?? '';
final latestVersion = tagName.replaceFirst('v', '');
final isPrerelease = releaseData['prerelease'] as bool? ?? false;
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)');
return null;
}
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();
final body = releaseData['body'] as String? ?? 'No changelog available';
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
final deviceArch = await _getDeviceArch();
_log.d('Device architecture: $deviceArch');
@@ -87,7 +119,7 @@ class UpdateChecker {
String? arm32Url;
String? universalUrl;
final assets = data['assets'] as List<dynamic>? ?? [];
final assets = releaseData['assets'] as List<dynamic>? ?? [];
for (final asset in assets) {
final name = (asset['name'] as String? ?? '').toLowerCase();
if (name.endsWith('.apk')) {
@@ -117,7 +149,7 @@ class UpdateChecker {
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
}
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
return UpdateInfo(
version: latestVersion,
@@ -125,6 +157,7 @@ class UpdateChecker {
downloadUrl: htmlUrl,
apkDownloadUrl: apkUrl,
publishedAt: publishedAt,
isPrerelease: isPrerelease,
);
} catch (e) {
_log.e('Error checking for updates: $e');
+11 -9
View File
@@ -43,6 +43,7 @@ class AppTheme {
static ThemeData dark({
ColorScheme? dynamicScheme,
Color? seedColor,
bool isAmoled = false,
}) {
final scheme = dynamicScheme ??
ColorScheme.fromSeed(
@@ -53,7 +54,8 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
appBarTheme: _appBarTheme(scheme),
scaffoldBackgroundColor: isAmoled ? Colors.black : null,
appBarTheme: _appBarTheme(scheme, isAmoled: isAmoled),
cardTheme: _cardTheme(scheme),
elevatedButtonTheme: _elevatedButtonTheme(scheme),
filledButtonTheme: _filledButtonTheme(scheme),
@@ -63,7 +65,7 @@ class AppTheme {
inputDecorationTheme: _inputDecorationTheme(scheme),
listTileTheme: _listTileTheme(scheme),
dialogTheme: _dialogTheme(scheme),
navigationBarTheme: _navigationBarTheme(scheme),
navigationBarTheme: _navigationBarTheme(scheme, isAmoled: isAmoled),
snackBarTheme: _snackBarTheme(scheme),
progressIndicatorTheme: _progressIndicatorTheme(scheme),
switchTheme: _switchTheme(scheme),
@@ -73,12 +75,12 @@ class AppTheme {
}
/// AppBar theme
static AppBarTheme _appBarTheme(ColorScheme scheme) => AppBarTheme(
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
elevation: 0,
scrolledUnderElevation: 3,
backgroundColor: scheme.surface,
scrolledUnderElevation: isAmoled ? 0 : 3,
backgroundColor: isAmoled ? Colors.black : scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: scheme.surfaceTint,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
centerTitle: true,
titleTextStyle: TextStyle(
color: scheme.onSurface,
@@ -180,12 +182,12 @@ class AppTheme {
);
/// Navigation bar theme
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme) =>
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
NavigationBarThemeData(
elevation: 0,
backgroundColor: scheme.surfaceContainer,
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
indicatorColor: scheme.secondaryContainer,
surfaceTintColor: scheme.surfaceTint,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
);
+21 -1
View File
@@ -40,12 +40,32 @@ class DynamicColorWrapper extends ConsumerWidget {
);
}
// Apply AMOLED mode if enabled (pure black background)
if (themeSettings.useAmoled) {
darkScheme = _applyAmoledColors(darkScheme);
}
// Build themes
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme);
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled);
return builder(lightTheme, darkTheme, themeSettings.themeMode);
},
);
}
/// Apply AMOLED colors - pure black background with adjusted surface colors
ColorScheme _applyAmoledColors(ColorScheme scheme) {
return scheme.copyWith(
surface: Colors.black,
onSurface: Colors.white,
surfaceContainerLowest: Colors.black,
surfaceContainerLow: const Color(0xFF0A0A0A),
surfaceContainer: const Color(0xFF121212),
surfaceContainerHigh: const Color(0xFF1A1A1A),
surfaceContainerHighest: const Color(0xFF222222),
inverseSurface: Colors.white,
onInverseSurface: Colors.black,
);
}
}
-16
View File
@@ -297,22 +297,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
ffmpeg_kit_flutter_new_audio:
dependency: "direct main"
description:
name: ffmpeg_kit_flutter_new_audio
sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
ffmpeg_kit_flutter_platform_interface:
dependency: transitive
description:
name: ffmpeg_kit_flutter_platform_interface
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
url: "https://pub.dev"
source: hosted
version: "0.2.1"
file:
dependency: transitive
description:
+3 -3
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 2.0.1+31
version: 2.1.0+41
environment:
sdk: ^3.10.0
@@ -50,8 +50,8 @@ dependencies:
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
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
open_filex: ^4.7.0
# Notifications
+82
View File
@@ -0,0 +1,82 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 2.1.0-preview2+40
environment:
sdk: ^3.10.0
dependencies:
flutter:
sdk: flutter
# State Management
flutter_riverpod: ^3.1.0
riverpod_annotation: ^4.0.0
# Navigation
go_router: ^17.0.1
# Storage & Persistence
shared_preferences: ^2.5.3
path_provider: ^2.1.5
# HTTP & Network
http: ^1.4.0
dio: ^5.8.0
# UI Components
cupertino_icons: ^1.0.8
cached_network_image: ^3.4.1
flutter_svg: ^2.1.0
# Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1
# Permissions
permission_handler: ^12.0.1
# File Picker
file_picker: ^10.3.0
# JSON Serialization
json_annotation: ^4.9.0
# Utils
url_launcher: ^6.3.1
device_info_plus: ^12.3.0
share_plus: ^12.0.1
receive_sharing_intent: ^1.8.1
logger: ^2.5.0
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
ffmpeg_kit_flutter_new_audio: ^2.0.0
open_filex: ^4.7.0
# Notifications
flutter_local_notifications: ^19.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
build_runner: ^2.10.4
riverpod_generator: ^4.0.0
json_serializable: ^6.11.2
flutter_launcher_icons: ^0.14.3
flutter_launcher_icons:
android: true
ios: true
image_path: "icon.png"
adaptive_icon_background: "#1a1a2e"
adaptive_icon_foreground: "icon.png"
ios_content_mode: scaleAspectFill
remove_alpha_ios: true
flutter:
uses-material-design: true
assets:
- assets/images/