From 3c3bbe516e1b6869ab06ca3105815e23852238ae Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 12 Feb 2026 00:32:40 +0700 Subject: [PATCH] v3.6.6: fix iOS downloads, metadata fallback, lossy quality display, audio duration accuracy --- CHANGELOG.md | 15 +++++++ ios/Runner/AppDelegate.swift | 48 ++++++++++++++++++++++ lib/constants/app_info.dart | 4 +- lib/screens/track_metadata_screen.dart | 56 ++++++++++++++++++++++++-- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a358ba4e..12f834cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d67aa7c1..7c1cb1b9 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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] diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 4fa3ab82..2188374a 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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'; diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 51c8bfef..c75bcfe3 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -436,6 +436,7 @@ class _TrackMetadataScreenState extends ConsumerState { } return _downloadItem!.downloadedAt; } + String? get _quality => _isLocalItem ? null : _downloadItem!.quality; String get cleanFilePath { @@ -443,6 +444,50 @@ class _TrackMetadataScreenState extends ConsumerState { 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 { 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 { 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 { ), ), ) - 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 { children: [ Expanded( child: Text( - cleanFilePath, + displayFilePath, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: colorScheme.onSurfaceVariant,