v3.6.6: fix iOS downloads, metadata fallback, lossy quality display, audio duration accuracy

This commit is contained in:
zarzet
2026-02-12 00:32:40 +07:00
parent a1d1ab1f0f
commit 3c3bbe516e
4 changed files with 117 additions and 6 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## [3.6.6] - 2026-02-12
### Fixed
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift (added in v3.6.5 for Android but not iOS)
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
- Fixed Track Metadata screen showing scan date instead of file date for local library items
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
- Added legacy download method fallback in PlatformBridge for platforms that haven't implemented `downloadByStrategy` yet
---
## [3.6.5] - 2026-02-10
### Highlights
+48
View File
@@ -94,6 +94,19 @@ import Gobackend // Import Go framework
let response = GobackendDownloadWithFallback(requestJson, &error)
if let error = error { throw error }
return response
case "downloadByStrategy":
let requestJson = call.arguments as! String
let response = GobackendDownloadByStrategy(requestJson, &error)
if let error = error { throw error }
return response
// Backward compatibility for older Flutter download routing
case "downloadFromYouTube":
let requestJson = call.arguments as! String
let response = GobackendDownloadFromYouTube(requestJson, &error)
if let error = error { throw error }
return response
case "getDownloadProgress":
let response = GobackendGetDownloadProgress()
@@ -209,6 +222,41 @@ import Gobackend // Import Go framework
case "cleanupConnections":
GobackendCleanupConnections()
return nil
case "downloadCoverToFile":
let args = call.arguments as! [String: Any]
let coverURL = args["cover_url"] as! String
let outputPath = args["output_path"] as! String
let maxQuality = args["max_quality"] as? Bool ?? true
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "extractCoverToFile":
let args = call.arguments as! [String: Any]
let audioPath = args["audio_path"] as! String
let outputPath = args["output_path"] as! String
GobackendExtractCoverToFile(audioPath, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "fetchAndSaveLyrics":
let args = call.arguments as! [String: Any]
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let spotifyId = args["spotify_id"] as! String
let durationMs = args["duration_ms"] as? Int64 ?? 0
let outputPath = args["output_path"] as! String
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "reEnrichFile":
let args = call.arguments as! [String: Any]
let requestJson = args["request_json"] as? String ?? "{}"
let response = GobackendReEnrichFile(requestJson, &error)
if let error = error { throw error }
return response
case "readFileMetadata":
let args = call.arguments as! [String: Any]
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.6.5';
static const String buildNumber = '79';
static const String version = '3.6.6';
static const String buildNumber = '80';
static const String fullVersion = '$version+$buildNumber';
+52 -4
View File
@@ -436,6 +436,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
return _downloadItem!.downloadedAt;
}
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath {
@@ -443,6 +444,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
String _formatPathForDisplay(String pathOrUri) {
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
return pathOrUri;
}
try {
final uri = Uri.parse(pathOrUri);
final segments = uri.pathSegments;
String? documentId;
final documentIndex = segments.indexOf('document');
if (documentIndex != -1 && documentIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[documentIndex + 1]);
}
if (documentId == null || documentId.isEmpty) {
final treeIndex = segments.indexOf('tree');
if (treeIndex != -1 && treeIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[treeIndex + 1]);
}
}
if (documentId == null || documentId.isEmpty) return pathOrUri;
final separatorIndex = documentId.indexOf(':');
if (separatorIndex <= 0) return documentId;
final volumeId = documentId.substring(0, separatorIndex);
final relativePath = documentId
.substring(separatorIndex + 1)
.replaceAll('\\', '/');
if (volumeId.toLowerCase() == 'primary') {
if (relativePath.isEmpty) return '/storage/emulated/0';
return '/storage/emulated/0/$relativePath';
}
if (relativePath.isEmpty) return volumeId;
return 'SD Card/$relativePath';
} catch (_) {
return pathOrUri;
}
}
void _markMetadataChanged() {
_hasMetadataChanges = true;
}
@@ -923,7 +968,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download
String? audioQualityStr;
final fileName = _filePath.split('/').last;
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '';
@@ -1045,7 +1090,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool fileExists,
int? fileSize,
) {
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: 'Unknown';
@@ -1166,7 +1212,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
)
else if (bitDepth != null && bitDepth! > 0 && sampleRate != null)
else if (bitDepth != null &&
bitDepth! > 0 &&
sampleRate != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -1232,7 +1280,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
children: [
Expanded(
child: Text(
cleanFilePath,
displayFilePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,