mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-12 23:36:39 +02:00
v3.3.5: Same as 3.3.1 but fixes crash issues caused by FFmpeg
Changes: - Fix FFmpeg crash issues during M4A to MP3/Opus conversion - Add format picker (MP3/Opus) when selecting Tidal Lossy 320kbps - Fix Deezer album blank screen when opened from home - LRC file generation now follows lyrics mode setting - Version bump to 3.3.5 (build 70)
This commit is contained in:
@@ -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 = '3.3.1';
|
||||
static const String buildNumber = '68';
|
||||
static const String version = '3.3.5';
|
||||
static const String buildNumber = '70';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -150,15 +150,12 @@ class DownloadHistoryState {
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
/// O(1) check if spotify_id exists
|
||||
bool isDownloaded(String spotifyId) =>
|
||||
_downloadedSpotifyIds.contains(spotifyId);
|
||||
|
||||
/// O(1) lookup by spotify_id
|
||||
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
||||
_bySpotifyId[spotifyId];
|
||||
|
||||
/// O(1) lookup by ISRC
|
||||
|
||||
DownloadHistoryItem? getByIsrc(String isrc) =>
|
||||
_byIsrc[isrc];
|
||||
|
||||
@@ -177,7 +174,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return DownloadHistoryState();
|
||||
}
|
||||
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
void _loadFromDatabaseSync() {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
@@ -193,7 +189,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_historyLog.i('Migrated history from SharedPreferences to SQLite');
|
||||
}
|
||||
|
||||
// Migrate iOS paths if container UUID changed after app update
|
||||
if (Platform.isIOS) {
|
||||
final pathsMigrated = await _db.migrateIosContainerPaths();
|
||||
if (pathsMigrated) {
|
||||
@@ -264,12 +259,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return state.getBySpotifyId(spotifyId);
|
||||
}
|
||||
|
||||
/// O(1) lookup by ISRC
|
||||
DownloadHistoryItem? getByIsrc(String isrc) {
|
||||
return state.getByIsrc(isrc);
|
||||
}
|
||||
|
||||
/// Async version with database lookup (for cases where in-memory might be stale)
|
||||
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
|
||||
final inMemory = state.getBySpotifyId(spotifyId);
|
||||
if (inMemory != null) return inMemory;
|
||||
@@ -286,7 +279,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Get database stats for debugging
|
||||
Future<int> getDatabaseCount() async {
|
||||
return await _db.getCount();
|
||||
}
|
||||
@@ -722,7 +714,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final isSingle = track.isSingle;
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
|
||||
// New option: Singles folder inside Artist folder
|
||||
if (albumFolderStructure == 'artist_album_singles') {
|
||||
if (isSingle) {
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
|
||||
@@ -736,7 +727,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Existing behavior: Separate Albums/ and Singles/ at root
|
||||
if (isSingle) {
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
||||
@@ -804,7 +794,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// Extract year from release date (format: "2005-06-13" or "2005")
|
||||
String? _extractYear(String? releaseDate) {
|
||||
if (releaseDate == null || releaseDate.isEmpty) return null;
|
||||
final match = _yearRegex.firstMatch(releaseDate);
|
||||
@@ -1075,7 +1064,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same logic as Go backend cover.go
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02';
|
||||
const spotifySize640 = 'ab67616d0000b273';
|
||||
@@ -1192,7 +1180,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
// Skip instrumental tracks (no lyrics to embed)
|
||||
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
@@ -1323,7 +1310,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('MP3 Metadata map content: $metadata');
|
||||
|
||||
if (settings.embedLyrics) {
|
||||
final lyricsMode = settings.lyricsMode;
|
||||
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
|
||||
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
|
||||
|
||||
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
|
||||
try {
|
||||
final durationMs = track.duration * 1000;
|
||||
|
||||
@@ -1336,12 +1327,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
|
||||
if (shouldEmbed) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
|
||||
}
|
||||
|
||||
if (shouldSaveExternal) {
|
||||
try {
|
||||
final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc');
|
||||
await File(lrcPath).writeAsString(lrcContent);
|
||||
_log.d('External LRC file saved: $lrcPath');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save external LRC file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
|
||||
_log.w('Failed to fetch lyrics for MP3: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1461,7 +1464,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Opus Metadata map content: $metadata');
|
||||
|
||||
if (settings.embedLyrics) {
|
||||
// Handle lyrics based on lyricsMode setting
|
||||
final lyricsMode = settings.lyricsMode;
|
||||
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
|
||||
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
|
||||
|
||||
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
|
||||
try {
|
||||
final durationMs = track.duration * 1000;
|
||||
|
||||
@@ -1474,11 +1482,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
|
||||
// Embed lyrics in file metadata if mode is 'embed' or 'both'
|
||||
if (shouldEmbed) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
|
||||
}
|
||||
|
||||
// Save external LRC file if mode is 'external' or 'both'
|
||||
if (shouldSaveExternal) {
|
||||
try {
|
||||
final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc');
|
||||
await File(lrcPath).writeAsString(lrcContent);
|
||||
_log.d('External LRC file saved: $lrcPath');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save external LRC file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch lyrics for Opus embedding: $e');
|
||||
_log.w('Failed to fetch lyrics for Opus: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1805,7 +1827,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
final quality = item.qualityOverride ?? state.audioQuality;
|
||||
|
||||
// Fetch extended metadata (genre, label) from Deezer if available
|
||||
String? genre;
|
||||
String? label;
|
||||
|
||||
@@ -2148,7 +2169,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (e) {
|
||||
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||
}
|
||||
} // end else (not HIGH quality)
|
||||
}
|
||||
}
|
||||
|
||||
final itemAfterDownload = state.items.firstWhere(
|
||||
|
||||
@@ -92,10 +92,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
});
|
||||
|
||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||
// Use provided tracks if not empty, otherwise try cache
|
||||
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
||||
_tracks = widget.tracks;
|
||||
} else {
|
||||
_tracks = _AlbumCache.get(widget.albumId);
|
||||
}
|
||||
_artistId = widget.artistId; // Use provided artist ID if available
|
||||
|
||||
if (_tracks == null) {
|
||||
if (_tracks == null || _tracks!.isEmpty) {
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
/// FFmpeg service for audio conversion and remuxing
|
||||
/// Uses ffmpeg_kit_flutter_new_audio plugin
|
||||
class FFmpegService {
|
||||
static Future<FFmpegResult> _execute(String command) async {
|
||||
try {
|
||||
@@ -48,16 +46,12 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert M4A (AAC) to lossy format (MP3 or Opus)
|
||||
/// format: 'mp3' or 'opus'
|
||||
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
|
||||
static Future<String?> convertM4aToLossy(
|
||||
String inputPath, {
|
||||
required String format,
|
||||
String? bitrate,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
// Extract bitrate value from format like 'mp3_320' -> '320k'
|
||||
String bitrateValue = format == 'opus' ? '128k' : '320k';
|
||||
if (bitrate != null && bitrate.contains('_')) {
|
||||
final parts = bitrate.split('_');
|
||||
@@ -71,11 +65,9 @@ class FFmpegService {
|
||||
|
||||
String command;
|
||||
if (format == 'opus') {
|
||||
// M4A -> Opus conversion
|
||||
command =
|
||||
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||
} else {
|
||||
// M4A -> MP3 conversion
|
||||
command =
|
||||
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||
}
|
||||
@@ -127,7 +119,6 @@ class FFmpegService {
|
||||
}) async {
|
||||
final outputPath = inputPath.replaceAll('.flac', '.opus');
|
||||
|
||||
// Opus in OGG container with VBR
|
||||
final command =
|
||||
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
|
||||
@@ -146,17 +137,13 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to lossy format based on format parameter
|
||||
/// format: 'mp3' or 'opus'
|
||||
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
|
||||
static Future<String?> convertFlacToLossy(
|
||||
String inputPath, {
|
||||
required String format,
|
||||
String? bitrate,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
// Extract bitrate value from format like 'mp3_320' -> '320k'
|
||||
String bitrateValue = '320k'; // default for mp3
|
||||
String bitrateValue = '320k';
|
||||
if (bitrate != null && bitrate.contains('_')) {
|
||||
final parts = bitrate.split('_');
|
||||
if (parts.length == 2) {
|
||||
@@ -385,8 +372,6 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed metadata to Opus file
|
||||
/// Uses METADATA_BLOCK_PICTURE tag for cover art (OGG/Vorbis standard)
|
||||
static Future<String?> embedMetadataToOpus({
|
||||
required String opusPath,
|
||||
String? coverPath,
|
||||
@@ -401,7 +386,6 @@ class FFmpegService {
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
// Embed metadata tags (Vorbis comments)
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
@@ -409,12 +393,10 @@ class FFmpegService {
|
||||
});
|
||||
}
|
||||
|
||||
// Embed cover art using METADATA_BLOCK_PICTURE
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
||||
if (pictureBlock != null) {
|
||||
// Escape special characters for shell
|
||||
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
|
||||
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
|
||||
@@ -471,19 +453,6 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Create METADATA_BLOCK_PICTURE base64 string for OGG/Opus cover art
|
||||
/// Format follows FLAC picture block specification:
|
||||
/// - 4 bytes: picture type (3 = front cover)
|
||||
/// - 4 bytes: MIME type length
|
||||
/// - n bytes: MIME type string
|
||||
/// - 4 bytes: description length
|
||||
/// - n bytes: description string
|
||||
/// - 4 bytes: width
|
||||
/// - 4 bytes: height
|
||||
/// - 4 bytes: color depth
|
||||
/// - 4 bytes: colors used (0 for non-indexed)
|
||||
/// - 4 bytes: picture data length
|
||||
/// - n bytes: picture data
|
||||
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
|
||||
try {
|
||||
final file = File(imagePath);
|
||||
@@ -494,7 +463,6 @@ class FFmpegService {
|
||||
|
||||
final imageData = await file.readAsBytes();
|
||||
|
||||
// Detect MIME type from file extension or magic bytes
|
||||
String mimeType;
|
||||
if (imagePath.toLowerCase().endsWith('.png')) {
|
||||
mimeType = 'image/png';
|
||||
@@ -502,7 +470,6 @@ class FFmpegService {
|
||||
imagePath.toLowerCase().endsWith('.jpeg')) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
// Check magic bytes
|
||||
if (imageData.length >= 8 &&
|
||||
imageData[0] == 0x89 && imageData[1] == 0x50 &&
|
||||
imageData[2] == 0x4E && imageData[3] == 0x47) {
|
||||
@@ -511,75 +478,61 @@ class FFmpegService {
|
||||
imageData[0] == 0xFF && imageData[1] == 0xD8) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
mimeType = 'image/jpeg'; // Default to JPEG
|
||||
mimeType = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
final mimeBytes = utf8.encode(mimeType);
|
||||
const description = ''; // Empty description
|
||||
const description = '';
|
||||
final descBytes = utf8.encode(description);
|
||||
|
||||
// Build the FLAC picture block
|
||||
// Total size: 4 + 4 + mimeLen + 4 + descLen + 4 + 4 + 4 + 4 + 4 + imageLen
|
||||
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
|
||||
4 + 4 + 4 + 4 + 4 + imageData.length;
|
||||
|
||||
final buffer = ByteData(blockSize);
|
||||
var offset = 0;
|
||||
|
||||
// Picture type: 3 = Front cover
|
||||
buffer.setUint32(offset, 3, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
// MIME type length
|
||||
buffer.setUint32(offset, mimeBytes.length, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
// MIME type string
|
||||
final blockBytes = Uint8List(blockSize);
|
||||
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
|
||||
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
|
||||
offset += mimeBytes.length;
|
||||
|
||||
// Description length
|
||||
final tempBuffer = ByteData(4);
|
||||
tempBuffer.setUint32(0, descBytes.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
// Description string
|
||||
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
|
||||
offset += descBytes.length;
|
||||
|
||||
// Width (0 = unknown)
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
// Height (0 = unknown)
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
// Color depth (0 = unknown)
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
// Colors used (0 for non-indexed)
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
// Picture data length
|
||||
tempBuffer.setUint32(0, imageData.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
// Picture data
|
||||
blockBytes.setRange(offset, offset + imageData.length, imageData);
|
||||
|
||||
// Base64 encode the entire block
|
||||
final base64String = base64Encode(blockBytes);
|
||||
|
||||
return base64String;
|
||||
@@ -596,7 +549,6 @@ class FFmpegService {
|
||||
final key = entry.key.toUpperCase();
|
||||
final value = entry.value;
|
||||
|
||||
// Map Vorbis comments to ID3v2 frame names
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
@@ -630,7 +582,6 @@ class FFmpegService {
|
||||
id3Map['lyrics'] = value;
|
||||
break;
|
||||
default:
|
||||
// Pass through other tags as-is
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class LogEntry {
|
||||
final String tag;
|
||||
final String message;
|
||||
final String? error;
|
||||
final bool isFromGo; // Track if this log came from Go backend
|
||||
final bool isFromGo;
|
||||
|
||||
LogEntry({
|
||||
required this.timestamp,
|
||||
@@ -47,8 +47,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
Timer? _goLogTimer;
|
||||
int _lastGoLogIndex = 0;
|
||||
|
||||
/// Whether logging is enabled (controlled by settings)
|
||||
/// User must enable "Detailed Logging" in settings to capture logs
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
static set loggingEnabled(bool value) {
|
||||
@@ -64,7 +62,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
int get length => _entries.length;
|
||||
|
||||
void add(LogEntry entry) {
|
||||
// Skip adding if logging is disabled (except for errors which are always logged)
|
||||
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||
return;
|
||||
}
|
||||
@@ -76,7 +73,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Start polling Go backend logs
|
||||
void startGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
@@ -84,13 +80,11 @@ class LogBuffer extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop polling Go backend logs
|
||||
void stopGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = null;
|
||||
}
|
||||
|
||||
/// Fetch logs from Go backend since last index
|
||||
Future<void> _fetchGoLogs() async {
|
||||
try {
|
||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||
@@ -103,7 +97,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
final tag = log['tag'] as String? ?? 'Go';
|
||||
final message = log['message'] as String? ?? '';
|
||||
|
||||
// Parse timestamp (format: "15:04:05.000")
|
||||
DateTime parsedTime = DateTime.now();
|
||||
if (timestamp.isNotEmpty) {
|
||||
try {
|
||||
@@ -221,7 +214,6 @@ class BufferedOutput extends LogOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Global logger instance for the app
|
||||
final log = Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
|
||||
@@ -218,8 +218,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
subtitle: quality.description ?? '',
|
||||
icon: _getQualityIcon(quality.id),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
// For Tidal HIGH quality, show format picker first
|
||||
if (_selectedService == 'tidal' && quality.id == 'HIGH') {
|
||||
_showLossyFormatPicker(context);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -250,6 +255,102 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
return Icons.music_note;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLossyFormatPicker(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final currentFormat = settings.tidalHighFormat;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (modalContext) => 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, 16, 24, 8),
|
||||
child: Text(
|
||||
'Select Lossy Format',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose output format for 320kbps lossy download',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('MP3 320kbps'),
|
||||
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||
trailing: currentFormat == 'mp3_320'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(modalContext); // Close format picker
|
||||
Navigator.pop(context); // Close service picker
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('Opus 128kbps'),
|
||||
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||
trailing: currentFormat == 'opus_128'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||
Navigator.pop(modalContext); // Close format picker
|
||||
Navigator.pop(context); // Close service picker
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user