mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-30 15:58:51 +02:00
fix: harden download errors and re-enrich sidecars
This commit is contained in:
@@ -6209,6 +6209,18 @@ abstract class AppLocalizations {
|
||||
/// **'Download completed'**
|
||||
String get queueDownloadCompleted;
|
||||
|
||||
/// Title shown on a failed queue item when the download service rate limits requests
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service rate limited'**
|
||||
String get queueRateLimitTitle;
|
||||
|
||||
/// Explanation shown on a failed queue item when the download service rate limits requests
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.'**
|
||||
String get queueRateLimitMessage;
|
||||
|
||||
/// Accessibility label for picking an accent color
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -3696,6 +3696,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3667,6 +3667,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3661,6 +3661,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3665,6 +3665,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3662,6 +3662,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3653,6 +3653,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Layanan sedang membatasi permintaan';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3649,6 +3649,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3642,6 +3642,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3662,6 +3662,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3661,6 +3661,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3721,6 +3721,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3688,6 +3688,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3721,6 +3721,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -3661,6 +3661,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
|
||||
@@ -4808,6 +4808,14 @@
|
||||
"@queueDownloadCompleted": {
|
||||
"description": "Accessibility label for completed download state in queue"
|
||||
},
|
||||
"queueRateLimitTitle": "Service rate limited",
|
||||
"@queueRateLimitTitle": {
|
||||
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
|
||||
"@queueRateLimitMessage": {
|
||||
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"appearanceSelectAccentColor": "Select accent color {hex}",
|
||||
"@appearanceSelectAccentColor": {
|
||||
"description": "Accessibility label for picking an accent color",
|
||||
|
||||
@@ -4614,5 +4614,13 @@
|
||||
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
|
||||
"@downloadFallbackExtensionsSubtitle": {
|
||||
"description": "Subtitle for fallback extensions item"
|
||||
},
|
||||
"queueRateLimitTitle": "Layanan sedang membatasi permintaan",
|
||||
"@queueRateLimitTitle": {
|
||||
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"queueRateLimitMessage": "Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.",
|
||||
"@queueRateLimitMessage": {
|
||||
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class DownloadItem {
|
||||
case DownloadErrorType.notFound:
|
||||
return 'Song not found on any service';
|
||||
case DownloadErrorType.rateLimit:
|
||||
return 'Rate limit reached, try again later';
|
||||
return 'Service rate limit reached. Wait before retrying.';
|
||||
case DownloadErrorType.network:
|
||||
return 'Connection failed, check your internet';
|
||||
case DownloadErrorType.permission:
|
||||
|
||||
@@ -6551,6 +6551,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
||||
final lowerMsg = errorMsg.toLowerCase();
|
||||
if (errorMsg.contains('429') ||
|
||||
lowerMsg.contains('rate limit') ||
|
||||
lowerMsg.contains('too many requests')) {
|
||||
return DownloadErrorType.rateLimit;
|
||||
}
|
||||
if (lowerMsg.contains('not found') ||
|
||||
lowerMsg.contains('not available') ||
|
||||
lowerMsg.contains('no results')) {
|
||||
return DownloadErrorType.notFound;
|
||||
}
|
||||
if (lowerMsg.contains('permission') ||
|
||||
lowerMsg.contains('operation not permitted') ||
|
||||
lowerMsg.contains('access denied')) {
|
||||
return DownloadErrorType.permission;
|
||||
}
|
||||
if (lowerMsg.contains('network') ||
|
||||
lowerMsg.contains('connection') ||
|
||||
lowerMsg.contains('timeout') ||
|
||||
lowerMsg.contains('dial')) {
|
||||
return DownloadErrorType.network;
|
||||
}
|
||||
return DownloadErrorType.unknown;
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
@@ -8723,7 +8749,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
errorType = DownloadErrorType.permission;
|
||||
break;
|
||||
default:
|
||||
errorType = DownloadErrorType.unknown;
|
||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||
}
|
||||
|
||||
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
||||
@@ -8777,6 +8803,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
errorMsg.contains('track not found on Deezer')) {
|
||||
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||
errorType = DownloadErrorType.notFound;
|
||||
} else {
|
||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
|
||||
@@ -863,6 +863,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasValue(downloadedCoverPath)) {
|
||||
@@ -875,6 +876,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
@@ -890,6 +900,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
@@ -912,6 +923,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
if (method == 'native') {
|
||||
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
|
||||
@@ -4388,6 +4388,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasTextValue(downloadedCoverPath)) {
|
||||
@@ -4400,6 +4401,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
@@ -4415,6 +4425,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
@@ -4437,6 +4448,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
if (method == 'native') {
|
||||
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
@@ -5664,12 +5680,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.errorMessage,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(color: colorScheme.error),
|
||||
_buildDownloadFailureMessage(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -5686,6 +5700,70 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadFailureMessage(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
if (item.errorType != DownloadErrorType.rateLimit) {
|
||||
return Text(
|
||||
item.errorMessage,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.error),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.tertiary.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.hourglass_top_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.queueRateLimitTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.l10n.queueRateLimitMessage,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
||||
final coverSize = _queueCoverSize();
|
||||
|
||||
|
||||
@@ -2933,6 +2933,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'cover_url': _coverUrl ?? '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': _spotifyId ?? '',
|
||||
'track_name': trackName,
|
||||
@@ -2979,7 +2980,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
if (method == 'native') {
|
||||
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
|
||||
// FLAC - handled natively by Go (SAF write-back handled in Kotlin).
|
||||
// External .lrc sidecar for filesystem files is written here; SAF
|
||||
// sidecars are created natively in the Kotlin reEnrichFile handler.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: cleanFilePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
await _refreshEmbeddedCoverPreview(force: true);
|
||||
_markMetadataChanged();
|
||||
await _syncDownloadHistoryMetadata();
|
||||
@@ -3073,6 +3080,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(
|
||||
safUri: safUri,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
if (tempPath != null && tempPath.isNotEmpty) {
|
||||
@@ -3082,6 +3093,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: cleanFilePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
await _refreshEmbeddedCoverPreview(force: true);
|
||||
_markMetadataChanged();
|
||||
await _syncDownloadHistoryMetadata();
|
||||
|
||||
@@ -817,6 +817,15 @@ class PlatformBridge {
|
||||
return map['success'] == true;
|
||||
}
|
||||
|
||||
static Future<bool> writeSafSidecarLrc(String safUri, String lyrics) async {
|
||||
final result = await _channel.invokeMethod('writeSafSidecarLrc', {
|
||||
'saf_uri': safUri,
|
||||
'lyrics': lyrics,
|
||||
});
|
||||
final map = _decodeRequiredMapResult(result, 'writeSafSidecarLrc');
|
||||
return map['success'] == true;
|
||||
}
|
||||
|
||||
static Future<void> startDownloadService({
|
||||
String trackName = '',
|
||||
String artistName = '',
|
||||
|
||||
@@ -22,6 +22,57 @@ String _sidecarLrcPath(String path) {
|
||||
return '$path.lrc';
|
||||
}
|
||||
|
||||
/// Writes a ".lrc" sidecar next to a re-enriched audio file when the Go backend
|
||||
/// result requests it (`write_external_lrc`), honoring the user's lyrics mode.
|
||||
///
|
||||
/// This handles the filesystem case only. SAF (`content://`) files are written
|
||||
/// centrally by the Kotlin `reEnrichFile` handler, which still holds the
|
||||
/// original document URI, so callers should skip those here (they are detected
|
||||
/// and ignored). Best-effort: returns true only when a sidecar was actually
|
||||
/// written, and never throws.
|
||||
Future<bool> writeReEnrichSidecarLrc({
|
||||
required String audioFilePath,
|
||||
required Map<String, dynamic> reEnrichResult,
|
||||
}) async {
|
||||
if (reEnrichResult['write_external_lrc'] != true) return false;
|
||||
|
||||
// SAF documents are handled natively in Kotlin; nothing to do from Dart.
|
||||
if (isContentUri(audioFilePath)) return false;
|
||||
|
||||
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
||||
if (lrc.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final lrcPath = _sidecarLrcPath(audioFilePath);
|
||||
await File(lrcPath).writeAsString(lrc);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a SAF ".lrc" sidecar after a FFmpeg re-enrich write-back succeeds.
|
||||
///
|
||||
/// Native FLAC re-enrich handles SAF sidecars in Kotlin after the direct
|
||||
/// write-back. This helper is for the FFmpeg path, where Dart owns the final
|
||||
/// `writeTempToSaf` success/failure decision.
|
||||
Future<bool> writeReEnrichSafSidecarLrc({
|
||||
required String safUri,
|
||||
required Map<String, dynamic> reEnrichResult,
|
||||
}) async {
|
||||
if (reEnrichResult['write_external_lrc'] != true) return false;
|
||||
if (!isContentUri(safUri)) return false;
|
||||
|
||||
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
||||
if (lrc.isEmpty) return false;
|
||||
|
||||
try {
|
||||
return await PlatformBridge.writeSafSidecarLrc(safUri, lrc);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureLyricsMetadataForConversion({
|
||||
required Map<String, String> metadata,
|
||||
required String sourcePath,
|
||||
|
||||
@@ -12,60 +12,107 @@ class AppAnnouncementDialog extends StatelessWidget {
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
void _close(BuildContext context) {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Future<void> _openCta(BuildContext context) async {
|
||||
final ctaUrl = announcement.ctaUrl;
|
||||
if (ctaUrl == null || ctaUrl.isEmpty) return;
|
||||
if (ctaUrl == null || ctaUrl.isEmpty) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(ctaUrl);
|
||||
if (uri == null) return;
|
||||
if (uri == null) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
bool launched;
|
||||
try {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {
|
||||
launched = false;
|
||||
}
|
||||
if (!launched) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
onDismiss();
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
void _showCtaOpenFailed(BuildContext context) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
const SnackBar(content: Text('Unable to open link. Please try again.')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isUrgent = announcement.priority.toLowerCase() == 'high';
|
||||
|
||||
return AlertDialog(
|
||||
icon: Icon(
|
||||
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
|
||||
color: isUrgent ? colorScheme.error : colorScheme.primary,
|
||||
),
|
||||
title: Text(announcement.title),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
announcement.message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(height: 1.45),
|
||||
// The notice can only be closed through an explicit affordance: never by
|
||||
// tapping the scrim or the system back button (handled by the barrier and
|
||||
// PopScope below). Always keep at least one way out (the X). A
|
||||
// non-dismissible announcement may omit the X only when it carries a CTA,
|
||||
// so the user can never get permanently trapped.
|
||||
final showCloseButton = announcement.dismissible || !announcement.hasCta;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (announcement.hasCta)
|
||||
FilledButton(
|
||||
onPressed: () => _openCta(context),
|
||||
child: Text(announcement.ctaLabel!),
|
||||
),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
|
||||
color: isUrgent ? colorScheme.error : colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(announcement.title),
|
||||
),
|
||||
),
|
||||
if (showCloseButton)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _close(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
announcement.message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(height: 1.45),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: actions.isEmpty ? null : actions,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
announcement.dismissible
|
||||
? MaterialLocalizations.of(context).closeButtonLabel
|
||||
: 'OK',
|
||||
),
|
||||
),
|
||||
if (announcement.hasCta)
|
||||
FilledButton(
|
||||
onPressed: () => _openCta(context),
|
||||
child: Text(announcement.ctaLabel!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,10 +122,14 @@ Future<void> showAppAnnouncementDialog(
|
||||
required RemoteAnnouncement announcement,
|
||||
required VoidCallback onDismiss,
|
||||
}) {
|
||||
// barrierDismissible is false so a stray tap outside the dialog can no longer
|
||||
// close (and silently mark-as-seen) the notice. Dismissal — and the
|
||||
// mark-as-seen side effect in onDismiss — only happens via the explicit close
|
||||
// button or the CTA, both of which call onDismiss themselves.
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: announcement.dismissible,
|
||||
barrierDismissible: false,
|
||||
builder: (context) =>
|
||||
AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss),
|
||||
).whenComplete(onDismiss);
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user