mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
a470ed8d7c
36 files changed
+3690
-12
No files matched your search
@@ -4856,6 +4856,131 @@
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"downloadRedownloadFakeHiRes": "Re-download fake Hi-Res",
|
||||
"@downloadRedownloadFakeHiRes": {
|
||||
"description": "Setting title: automatically replace downloads that measure as fake Hi-Res"
|
||||
},
|
||||
"downloadRedownloadFakeHiResSubtitle": "Off by default. Check Hi-Res downloads and replace only when every audio sample is preserved. Keep the original if verification or processing fails",
|
||||
"@downloadRedownloadFakeHiResSubtitle": {
|
||||
"description": "Setting subtitle for the fake Hi-Res re-download toggle"
|
||||
},
|
||||
"hiResCheckVerdictFakeCertain": "Fake Hi-Res",
|
||||
"@hiResCheckVerdictFakeCertain": {
|
||||
"description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictFakeSuspect": "Possibly a filtered master",
|
||||
"@hiResCheckVerdictFakeSuspect": {
|
||||
"description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering"
|
||||
},
|
||||
"hiResCheckReasonSampleHold": "Every sample is repeated: upsampled by duplication",
|
||||
"@hiResCheckReasonSampleHold": {
|
||||
"description": "Reason: sample-and-hold upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonInterpolation": "In-between samples are linearly interpolated",
|
||||
"@hiResCheckReasonInterpolation": {
|
||||
"description": "Reason: linear-interpolation upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonImaging": "Content above 22 kHz mirrors the audible band",
|
||||
"@hiResCheckReasonImaging": {
|
||||
"description": "Reason: spectral imaging from upsampling detected"
|
||||
},
|
||||
"hiResCheckMusicContent": "Musical content",
|
||||
"@hiResCheckMusicContent": {
|
||||
"description": "Label for the highest frequency that still moves with the music"
|
||||
},
|
||||
"hiResCheckUltrasonicNoise": "Above {cutoff} only steady noise (typical of DSD or analog tape transfers): a {rate} file would hold all the music",
|
||||
"@hiResCheckUltrasonicNoise": {
|
||||
"description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music",
|
||||
"placeholders": {
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
},
|
||||
"rate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckTitle": "Hi-Res Authenticity",
|
||||
"@hiResCheckTitle": {
|
||||
"description": "Title of the fake Hi-Res check card"
|
||||
},
|
||||
"hiResCheckDescription": "Detect upsampled or bit-padded fake Hi-Res files",
|
||||
"@hiResCheckDescription": {
|
||||
"description": "Explains what the Hi-Res authenticity check does"
|
||||
},
|
||||
"hiResCheckRun": "Check",
|
||||
"@hiResCheckRun": {
|
||||
"description": "Button that starts the Hi-Res authenticity check"
|
||||
},
|
||||
"hiResCheckChecking": "Checking Hi-Res authenticity...",
|
||||
"@hiResCheckChecking": {
|
||||
"description": "Loading text while the Hi-Res check runs"
|
||||
},
|
||||
"hiResCheckVerdictFake": "Likely fake Hi-Res",
|
||||
"@hiResCheckVerdictFake": {
|
||||
"description": "Verdict: the file claims Hi-Res but does not measure as it"
|
||||
},
|
||||
"hiResCheckVerdictGenuine": "Measures as genuine Hi-Res",
|
||||
"@hiResCheckVerdictGenuine": {
|
||||
"description": "Verdict: no upsampling or padded bit depth found"
|
||||
},
|
||||
"hiResCheckVerdictStandard": "Standard definition, nothing to flag",
|
||||
"@hiResCheckVerdictStandard": {
|
||||
"description": "Verdict: neither sample rate nor bit depth claims Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictInconclusive": "Inconclusive: the analyzed segment is too quiet or short",
|
||||
"@hiResCheckVerdictInconclusive": {
|
||||
"description": "Verdict: analysis could not reach a reliable conclusion"
|
||||
},
|
||||
"hiResCheckUnsupported": "Only FLAC and WAV files can be checked",
|
||||
"@hiResCheckUnsupported": {
|
||||
"description": "Shown when the file format cannot be decoded by the Hi-Res check"
|
||||
},
|
||||
"hiResCheckCutoff": "Content cutoff",
|
||||
"@hiResCheckCutoff": {
|
||||
"description": "Label for the highest frequency with real content"
|
||||
},
|
||||
"hiResCheckBitsInUse": "Bits in use",
|
||||
"@hiResCheckBitsInUse": {
|
||||
"description": "Label for effective vs declared bit depth"
|
||||
},
|
||||
"hiResCheckDisclaimer": "The spectrum stops early, but nothing proves it was upsampled: it may be a genuine master low-pass filtered in mastering. It is not replaced automatically.",
|
||||
"@hiResCheckDisclaimer": {
|
||||
"description": "Caveat shown under a fake Hi-Res verdict"
|
||||
},
|
||||
"hiResCheckFailed": "Hi-Res check failed: {error}",
|
||||
"@hiResCheckFailed": {
|
||||
"description": "Error shown when the Hi-Res check fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonRate": "Declares {sampleRate} but content stops at ~{cutoff}",
|
||||
"@hiResCheckReasonRate": {
|
||||
"description": "Reason for a fake verdict caused by upsampling",
|
||||
"placeholders": {
|
||||
"sampleRate": {
|
||||
"type": "String"
|
||||
},
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonDepth": "Declares {declared}-bit but only {effective} bits carry data",
|
||||
"@hiResCheckReasonDepth": {
|
||||
"description": "Reason for a fake verdict caused by padded bit depth",
|
||||
"placeholders": {
|
||||
"declared": {
|
||||
"type": "int"
|
||||
},
|
||||
"effective": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
|
||||
@@ -4732,6 +4732,131 @@
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"downloadRedownloadFakeHiRes": "Riscarica i falsi Hi-Res",
|
||||
"@downloadRedownloadFakeHiRes": {
|
||||
"description": "Setting title: automatically replace downloads that measure as fake Hi-Res"
|
||||
},
|
||||
"downloadRedownloadFakeHiResSubtitle": "Disattivato per impostazione predefinita. Sostituisce i download Hi-Res solo se tutti i campioni audio sono preservati. Mantiene l’originale se la verifica o l’elaborazione non riesce",
|
||||
"@downloadRedownloadFakeHiResSubtitle": {
|
||||
"description": "Setting subtitle for the fake Hi-Res re-download toggle"
|
||||
},
|
||||
"hiResCheckVerdictFakeCertain": "Falso Hi-Res",
|
||||
"@hiResCheckVerdictFakeCertain": {
|
||||
"description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictFakeSuspect": "Forse un master filtrato",
|
||||
"@hiResCheckVerdictFakeSuspect": {
|
||||
"description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering"
|
||||
},
|
||||
"hiResCheckReasonSampleHold": "Ogni campione è ripetuto: ricampionato per duplicazione",
|
||||
"@hiResCheckReasonSampleHold": {
|
||||
"description": "Reason: sample-and-hold upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonInterpolation": "I campioni intermedi sono interpolati linearmente",
|
||||
"@hiResCheckReasonInterpolation": {
|
||||
"description": "Reason: linear-interpolation upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonImaging": "Il contenuto sopra i 22 kHz è lo specchio della banda udibile",
|
||||
"@hiResCheckReasonImaging": {
|
||||
"description": "Reason: spectral imaging from upsampling detected"
|
||||
},
|
||||
"hiResCheckMusicContent": "Contenuto musicale",
|
||||
"@hiResCheckMusicContent": {
|
||||
"description": "Label for the highest frequency that still moves with the music"
|
||||
},
|
||||
"hiResCheckUltrasonicNoise": "Sopra {cutoff} solo rumore costante (tipico dei trasferimenti da DSD o nastro analogico): un file a {rate} conterrebbe tutta la musica",
|
||||
"@hiResCheckUltrasonicNoise": {
|
||||
"description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music",
|
||||
"placeholders": {
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
},
|
||||
"rate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckTitle": "Autenticità Hi-Res",
|
||||
"@hiResCheckTitle": {
|
||||
"description": "Title of the fake Hi-Res check card"
|
||||
},
|
||||
"hiResCheckDescription": "Rileva file Hi-Res falsi, ricampionati o con bit riempiti",
|
||||
"@hiResCheckDescription": {
|
||||
"description": "Explains what the Hi-Res authenticity check does"
|
||||
},
|
||||
"hiResCheckRun": "Verifica",
|
||||
"@hiResCheckRun": {
|
||||
"description": "Button that starts the Hi-Res authenticity check"
|
||||
},
|
||||
"hiResCheckChecking": "Verifica dell'autenticità Hi-Res...",
|
||||
"@hiResCheckChecking": {
|
||||
"description": "Loading text while the Hi-Res check runs"
|
||||
},
|
||||
"hiResCheckVerdictFake": "Probabile falso Hi-Res",
|
||||
"@hiResCheckVerdictFake": {
|
||||
"description": "Verdict: the file claims Hi-Res but does not measure as it"
|
||||
},
|
||||
"hiResCheckVerdictGenuine": "Risulta Hi-Res autentico",
|
||||
"@hiResCheckVerdictGenuine": {
|
||||
"description": "Verdict: no upsampling or padded bit depth found"
|
||||
},
|
||||
"hiResCheckVerdictStandard": "Definizione standard, niente da segnalare",
|
||||
"@hiResCheckVerdictStandard": {
|
||||
"description": "Verdict: neither sample rate nor bit depth claims Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictInconclusive": "Inconcludente: il segmento analizzato è troppo silenzioso o breve",
|
||||
"@hiResCheckVerdictInconclusive": {
|
||||
"description": "Verdict: analysis could not reach a reliable conclusion"
|
||||
},
|
||||
"hiResCheckUnsupported": "Si possono verificare solo file FLAC e WAV",
|
||||
"@hiResCheckUnsupported": {
|
||||
"description": "Shown when the file format cannot be decoded by the Hi-Res check"
|
||||
},
|
||||
"hiResCheckCutoff": "Taglio del contenuto",
|
||||
"@hiResCheckCutoff": {
|
||||
"description": "Label for the highest frequency with real content"
|
||||
},
|
||||
"hiResCheckBitsInUse": "Bit usati",
|
||||
"@hiResCheckBitsInUse": {
|
||||
"description": "Label for effective vs declared bit depth"
|
||||
},
|
||||
"hiResCheckDisclaimer": "Lo spettro si ferma presto, ma nulla prova che sia ricampionato: potrebbe essere un master autentico filtrato in mastering. Non viene sostituito automaticamente.",
|
||||
"@hiResCheckDisclaimer": {
|
||||
"description": "Caveat shown under a fake Hi-Res verdict"
|
||||
},
|
||||
"hiResCheckFailed": "Verifica Hi-Res non riuscita: {error}",
|
||||
"@hiResCheckFailed": {
|
||||
"description": "Error shown when the Hi-Res check fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonRate": "Dichiara {sampleRate} ma il contenuto si ferma a ~{cutoff}",
|
||||
"@hiResCheckReasonRate": {
|
||||
"description": "Reason for a fake verdict caused by upsampling",
|
||||
"placeholders": {
|
||||
"sampleRate": {
|
||||
"type": "String"
|
||||
},
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonDepth": "Dichiara {declared} bit ma solo {effective} contengono dati",
|
||||
"@hiResCheckReasonDepth": {
|
||||
"description": "Reason for a fake verdict caused by padded bit depth",
|
||||
"placeholders": {
|
||||
"declared": {
|
||||
"type": "int"
|
||||
},
|
||||
"effective": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
|
||||
@@ -77,6 +77,8 @@ class AppSettings {
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final bool autoConvertDownloads;
|
||||
// Re-download a Hi-Res request at LOSSLESS when the file measures as fake.
|
||||
final bool redownloadFakeHiRes;
|
||||
final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus'
|
||||
final String autoConvertBitrate; // '128k', '192k', '256k', or '320k'
|
||||
final bool
|
||||
@@ -172,6 +174,7 @@ class AppSettings {
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.autoConvertDownloads = false,
|
||||
this.redownloadFakeHiRes = false,
|
||||
this.autoConvertFormat = 'mp3',
|
||||
this.autoConvertBitrate = '320k',
|
||||
this.useAllFilesAccess = false,
|
||||
@@ -260,6 +263,7 @@ class AppSettings {
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
bool? autoConvertDownloads,
|
||||
bool? redownloadFakeHiRes,
|
||||
String? autoConvertFormat,
|
||||
String? autoConvertBitrate,
|
||||
bool? useAllFilesAccess,
|
||||
@@ -353,6 +357,7 @@ class AppSettings {
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads,
|
||||
redownloadFakeHiRes: redownloadFakeHiRes ?? this.redownloadFakeHiRes,
|
||||
autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat,
|
||||
autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
|
||||
@@ -63,6 +63,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false,
|
||||
redownloadFakeHiRes: json['redownloadFakeHiRes'] as bool? ?? false,
|
||||
autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3',
|
||||
autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k',
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
@@ -152,6 +153,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'autoConvertDownloads': instance.autoConvertDownloads,
|
||||
'redownloadFakeHiRes': instance.redownloadFakeHiRes,
|
||||
'autoConvertFormat': instance.autoConvertFormat,
|
||||
'autoConvertBitrate': instance.autoConvertBitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/services/download_motion_artwork_source.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/hires_check_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/verification_notification.dart';
|
||||
@@ -57,6 +58,7 @@ part 'download_queue_provider_finalization.dart';
|
||||
part 'download_queue_provider_replaygain.dart';
|
||||
part 'download_queue_provider_embedding.dart';
|
||||
part 'download_queue_provider_single_item.dart';
|
||||
part 'download_queue_provider_hires_check.dart';
|
||||
|
||||
final _log = AppLogger('DownloadQueue');
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
part of 'download_queue_provider.dart';
|
||||
|
||||
// Per-track fake Hi-Res check (AppSettings.redownloadFakeHiRes), ported from
|
||||
// SpotiFLAC-Module-Version's --redownload-fake-hires.
|
||||
//
|
||||
// Runs inside one _DownloadRun, right after the file is decoded and before
|
||||
// anything is published, tagged or converted: the verdict decides which file
|
||||
// the rest of the pipeline works on. A flagged file is set aside, the same
|
||||
// track is downloaded again at LOSSLESS. The original is only deleted after
|
||||
// full PCM verification and successful finalization, publication and history.
|
||||
// If any step fails, including cancellation, the original is restored.
|
||||
|
||||
enum _FakeHiResOutcome {
|
||||
/// Nothing to do, or the replacement failed: finish with the current file.
|
||||
kept,
|
||||
|
||||
/// The verified replacement is already finalized and persisted.
|
||||
completed,
|
||||
|
||||
/// The item was cancelled or paused mid-replacement; stop the run.
|
||||
aborted,
|
||||
}
|
||||
|
||||
/// Everything a replacement attempt may overwrite, so a failed one can put
|
||||
/// the run back exactly as it was.
|
||||
class _FakeHiResRunState {
|
||||
_FakeHiResRunState(_DownloadRun run)
|
||||
: result = Map<String, dynamic>.from(run.result),
|
||||
quality = run.quality,
|
||||
safOutputExt = run.safOutputExt,
|
||||
safFileName = run.safFileName,
|
||||
safBaseName = run.safBaseName,
|
||||
finalSafFileName = run.finalSafFileName,
|
||||
effectiveSafMode = run.effectiveSafMode,
|
||||
effectiveOutputDir = run.effectiveOutputDir,
|
||||
trackToDownload = run.trackToDownload,
|
||||
appOutputDir = run.appOutputDir,
|
||||
filePath = run.filePath,
|
||||
wasExisting = run.wasExisting,
|
||||
actualQuality = run.actualQuality,
|
||||
resultOutputExt = run.resultOutputExt,
|
||||
shouldPreserveNativeM4a = run.shouldPreserveNativeM4a,
|
||||
decryptionDescriptor = run.decryptionDescriptor,
|
||||
probedFinalMetadata = run.probedFinalMetadata,
|
||||
externalLrcWritten = run.externalLrcWritten;
|
||||
|
||||
final Map<String, dynamic> result;
|
||||
final String quality;
|
||||
final String safOutputExt;
|
||||
final String? safFileName;
|
||||
final String? safBaseName;
|
||||
final String? finalSafFileName;
|
||||
final bool effectiveSafMode;
|
||||
final String effectiveOutputDir;
|
||||
final Track trackToDownload;
|
||||
final String? appOutputDir;
|
||||
final String? filePath;
|
||||
final bool wasExisting;
|
||||
final String actualQuality;
|
||||
final String? resultOutputExt;
|
||||
final bool shouldPreserveNativeM4a;
|
||||
final DownloadDecryptionDescriptor? decryptionDescriptor;
|
||||
final Map<String, dynamic>? probedFinalMetadata;
|
||||
final bool externalLrcWritten;
|
||||
|
||||
void restore(_DownloadRun run) {
|
||||
run.result = result;
|
||||
run.quality = quality;
|
||||
run.safOutputExt = safOutputExt;
|
||||
run.safFileName = safFileName;
|
||||
run.safBaseName = safBaseName;
|
||||
run.finalSafFileName = finalSafFileName;
|
||||
run.effectiveSafMode = effectiveSafMode;
|
||||
run.effectiveOutputDir = effectiveOutputDir;
|
||||
run.trackToDownload = trackToDownload;
|
||||
run.appOutputDir = appOutputDir;
|
||||
run.filePath = filePath;
|
||||
run.wasExisting = wasExisting;
|
||||
run.actualQuality = actualQuality;
|
||||
run.resultOutputExt = resultOutputExt;
|
||||
run.shouldPreserveNativeM4a = shouldPreserveNativeM4a;
|
||||
run.decryptionDescriptor = decryptionDescriptor;
|
||||
run.probedFinalMetadata = probedFinalMetadata;
|
||||
run.externalLrcWritten = externalLrcWritten;
|
||||
}
|
||||
}
|
||||
|
||||
extension _DownloadRunFakeHiRes on _DownloadRun {
|
||||
/// [requestTrack] is the track as it was sent to the backend, before the
|
||||
/// first result was merged into it: the replacement gets the same brief
|
||||
/// the first attempt did, not the first attempt's conclusions.
|
||||
Future<_FakeHiResOutcome> _replaceFakeHiResIfNeeded(
|
||||
Track requestTrack,
|
||||
) async {
|
||||
final path = filePath;
|
||||
if (!settings.redownloadFakeHiRes ||
|
||||
wasExisting ||
|
||||
path == null ||
|
||||
!HiResCheckService.isHiResQuality(quality)) {
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (isContentUri(path)) {
|
||||
// Already published to SAF: there is no local file to set aside, and
|
||||
// a heuristic is not worth deleting the only copy for.
|
||||
_log.d('[hires-check] skipped: output already published to SAF');
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (!_serviceOffersFakeHiResFallback()) {
|
||||
_log.d(
|
||||
'[hires-check] skipped: ${item.service} has no '
|
||||
'${HiResCheckService.fakeHiResFallbackQuality} quality',
|
||||
);
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
|
||||
final HiResCheckResult check;
|
||||
try {
|
||||
check = await HiResCheckService.check(path);
|
||||
} catch (e) {
|
||||
// Never a reason to fail a finished download.
|
||||
_log.w('[hires-check] skipped for ${item.track.name}: $e');
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (!check.supported || !check.isFake) {
|
||||
_log.d(
|
||||
'[hires-check] ${item.track.name}: '
|
||||
'${check.supported ? check.verdict : 'unsupported format'}',
|
||||
);
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (!check.redownloadSafe) {
|
||||
// Evidence on one axis cannot justify discarding the other axis.
|
||||
_log.w(
|
||||
'[hires-check] ${item.track.name} may be fake Hi-Res '
|
||||
'(${check.reason}); kept, not certain enough to replace',
|
||||
);
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
|
||||
_log.w(
|
||||
'[hires-check] ${item.track.name} looks like fake Hi-Res '
|
||||
'(${check.reason}); re-downloading as '
|
||||
'${HiResCheckService.fakeHiResFallbackQuality}',
|
||||
);
|
||||
|
||||
final saved = _FakeHiResRunState(this);
|
||||
var completed = false;
|
||||
var aborted = false;
|
||||
try {
|
||||
completed = await HiResCheckService.withOriginalBackup(path, (
|
||||
backup,
|
||||
) async {
|
||||
fakeHiResOriginalPath = backup;
|
||||
trackToDownload = requestTrack;
|
||||
result = <String, dynamic>{};
|
||||
filePath = null;
|
||||
probedFinalMetadata = null;
|
||||
externalLrcWritten = false;
|
||||
var finalized = false;
|
||||
try {
|
||||
await _useFakeHiResFallbackQuality();
|
||||
n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 0);
|
||||
if (!await _downloadAndMaybeFallback()) {
|
||||
aborted = true;
|
||||
return false;
|
||||
}
|
||||
aborted = await _shouldAbort('during fake Hi-Res re-download');
|
||||
if (aborted) return false;
|
||||
final replacementPath = result['file_path'] as String?;
|
||||
if (result['success'] != true ||
|
||||
result['already_exists'] == true ||
|
||||
normalizeOptionalString(replacementPath) == null ||
|
||||
isContentUri(replacementPath!)) {
|
||||
return false;
|
||||
}
|
||||
if (effectiveSafMode && result['saf_relative_dir'] is String) {
|
||||
effectiveOutputDir = n._sanitizeSafRelativeDir(
|
||||
result['saf_relative_dir'] as String,
|
||||
);
|
||||
}
|
||||
finalized = await _handleDownloadSuccess();
|
||||
return finalized;
|
||||
} finally {
|
||||
if (!finalized && result['already_exists'] != true) {
|
||||
for (final failedPath in {
|
||||
filePath,
|
||||
result['file_path'] as String?,
|
||||
}) {
|
||||
if (failedPath != null && failedPath != backup) {
|
||||
await deleteFile(failedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} on HiResOriginalRestoreException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
_log.w('[hires-check] re-download of ${item.track.name} failed: $e');
|
||||
} finally {
|
||||
fakeHiResOriginalPath = null;
|
||||
if (!completed) saved.restore(this);
|
||||
}
|
||||
if (completed) return _FakeHiResOutcome.completed;
|
||||
if (aborted || await _shouldAbort('after fake Hi-Res rollback')) {
|
||||
return _FakeHiResOutcome.aborted;
|
||||
}
|
||||
if (!await File(path).exists()) {
|
||||
throw StateError('Could not restore the original Hi-Res file: $path');
|
||||
}
|
||||
n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 1);
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
|
||||
/// Only LOSSLESS is a meaningful replacement. A provider that does not
|
||||
/// list it would silently fall back to its default, which may be the very
|
||||
/// Hi-Res tier that produced the fake.
|
||||
bool _serviceOffersFakeHiResFallback() {
|
||||
final service = item.service.trim().toLowerCase();
|
||||
final extension = extensionState.extensions
|
||||
.where((e) => e.id.toLowerCase() == service)
|
||||
.firstOrNull;
|
||||
final options = extension?.qualityOptions ?? const <QualityOption>[];
|
||||
return options.isEmpty ||
|
||||
options.any(
|
||||
(o) =>
|
||||
o.id.toUpperCase() == HiResCheckService.fakeHiResFallbackQuality,
|
||||
);
|
||||
}
|
||||
|
||||
/// Points the next backend request at LOSSLESS. The output folder stays
|
||||
/// as resolved (including any storage fallback); only what depends on the
|
||||
/// quality is rebuilt.
|
||||
Future<void> _useFakeHiResFallbackQuality() async {
|
||||
quality = HiResCheckService.fakeHiResFallbackQuality;
|
||||
safOutputExt = n._determineOutputExt(quality, item.service);
|
||||
if (effectiveSafMode) {
|
||||
final name = await n._buildSafFileNameForItem(
|
||||
item,
|
||||
trackToDownload,
|
||||
filenameFormat: effectiveFilenameFormat,
|
||||
quality: quality,
|
||||
outputExt: safOutputExt,
|
||||
);
|
||||
safFileName = name;
|
||||
safBaseName = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
||||
finalSafFileName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -869,6 +869,14 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
var quality = item.qualityOverride ?? state.audioQuality;
|
||||
if (quality == 'DEFAULT') quality = state.audioQuality;
|
||||
|
||||
// The fake Hi-Res check and its LOSSLESS re-download live in the Dart
|
||||
// run (_DownloadRun._replaceFakeHiResIfNeeded); the native finalizer has
|
||||
// no equivalent, so these items stay on the Dart queue.
|
||||
if (settings.redownloadFakeHiRes &&
|
||||
HiResCheckService.isHiResQuality(quality)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final isSafMode = _isSafMode(settings);
|
||||
final rawOutputDir = isSafMode
|
||||
? _buildRelativeOutputDir(
|
||||
|
||||
@@ -132,6 +132,8 @@ class _DownloadRun {
|
||||
Map<String, dynamic>? probedFinalMetadata;
|
||||
bool externalLrcWritten = false;
|
||||
final Map<String, String> _directoryScopes = {};
|
||||
bool fakeHiResChecked = false;
|
||||
String? fakeHiResOriginalPath;
|
||||
|
||||
Future<void> _run() async {
|
||||
final normalizedService = n._normalizeQueuedService(item.service);
|
||||
@@ -729,6 +731,7 @@ class _DownloadRun {
|
||||
decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult(
|
||||
result,
|
||||
);
|
||||
final requestTrack = trackToDownload;
|
||||
trackToDownload = buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
@@ -741,6 +744,18 @@ class _DownloadRun {
|
||||
await _applyFormatHandling(actualService);
|
||||
stageCompleted('format and metadata');
|
||||
|
||||
final originalHiResPath = fakeHiResOriginalPath;
|
||||
if (originalHiResPath != null &&
|
||||
(filePath == null ||
|
||||
!await HiResCheckService.replacementPreservesAudio(
|
||||
originalHiResPath,
|
||||
filePath!,
|
||||
))) {
|
||||
throw StateError(
|
||||
'Lossless replacement does not preserve the original audio',
|
||||
);
|
||||
}
|
||||
|
||||
if (await _shouldAbort(
|
||||
'during finalization',
|
||||
deleteFileOnAbort: filePath,
|
||||
@@ -748,6 +763,19 @@ class _DownloadRun {
|
||||
return false;
|
||||
}
|
||||
|
||||
// At most once per run: the LOSSLESS replacement is not checked again.
|
||||
if (!fakeHiResChecked) {
|
||||
fakeHiResChecked = true;
|
||||
switch (await _replaceFakeHiResIfNeeded(requestTrack)) {
|
||||
case _FakeHiResOutcome.completed:
|
||||
return true;
|
||||
case _FakeHiResOutcome.aborted:
|
||||
return false;
|
||||
case _FakeHiResOutcome.kept:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final deferredSafPublish =
|
||||
effectiveSafMode &&
|
||||
result['saf_deferred_publish'] == true &&
|
||||
@@ -838,6 +866,23 @@ class _DownloadRun {
|
||||
);
|
||||
}
|
||||
|
||||
if (originalHiResPath != null &&
|
||||
!await HiResCheckService.replacementPreservesAudio(
|
||||
originalHiResPath,
|
||||
filePath!,
|
||||
)) {
|
||||
throw StateError(
|
||||
'Finalized replacement no longer preserves the original audio',
|
||||
);
|
||||
}
|
||||
|
||||
if (await _shouldAbort(
|
||||
'before publishing finalized audio',
|
||||
deleteFileOnAbort: wasExisting ? null : filePath,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deferredSafPublish && !await _publishDeferredSafOutputOnce()) {
|
||||
throw StateError('Failed to publish deferred SAF output');
|
||||
}
|
||||
@@ -1762,13 +1807,19 @@ class _DownloadRun {
|
||||
);
|
||||
},
|
||||
);
|
||||
await n._notificationService.showDownloadComplete(
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
completedCount: n._completedInSession,
|
||||
totalCount: n._totalQueuedAtStart,
|
||||
alreadyInLibrary: wasExisting,
|
||||
);
|
||||
try {
|
||||
await n._notificationService.showDownloadComplete(
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
completedCount: n._completedInSession,
|
||||
totalCount: n._totalQueuedAtStart,
|
||||
alreadyInLibrary: wasExisting,
|
||||
);
|
||||
} catch (e) {
|
||||
// Audio and history are already committed. A notification failure
|
||||
// must not roll back a valid replacement or its persisted history.
|
||||
_log.w('Download completed but notification failed: $e');
|
||||
}
|
||||
n.removeItem(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,6 +792,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setRedownloadFakeHiRes(bool enabled) {
|
||||
state = state.copyWith(redownloadFakeHiRes: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoConvertFormat(String format) {
|
||||
state = state.copyWith(
|
||||
autoConvertFormat: normalizeAutoConvertFormat(format),
|
||||
|
||||
@@ -107,6 +107,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
showDivider: true,
|
||||
),
|
||||
],
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.verified_outlined,
|
||||
title: context.l10n.downloadRedownloadFakeHiRes,
|
||||
subtitle: context.l10n.downloadRedownloadFakeHiResSubtitle,
|
||||
value: settings.redownloadFakeHiRes,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setRedownloadFakeHiRes(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: context.l10n.downloadAutoConvert,
|
||||
|
||||
@@ -330,6 +330,12 @@ class SettingsSearchCatalog {
|
||||
title: l10n.downloadLossyFormat,
|
||||
keywords: const ['mp3', 'aac', 'opus'],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.verified_outlined,
|
||||
title: l10n.downloadRedownloadFakeHiRes,
|
||||
subtitle: l10n.downloadRedownloadFakeHiResSubtitle,
|
||||
keywords: const ['hi-res', 'hires', 'fake', 'upsampled', '24-bit'],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: l10n.downloadAutoConvert,
|
||||
|
||||
@@ -88,6 +88,16 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
filePath: _filePath,
|
||||
codecHint: _storedAudioFormat,
|
||||
),
|
||||
if (HiResCheckCard.isCandidate(
|
||||
_filePath,
|
||||
_storedAudioFormat,
|
||||
)) ...[
|
||||
const SizedBox(height: 16),
|
||||
HiResCheckCard(
|
||||
filePath: _filePath,
|
||||
formatHint: _storedAudioFormat,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
if (!context.isMornye) ...[
|
||||
|
||||
@@ -64,6 +64,7 @@ import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/hires_check_card.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
class HiResOriginalRestoreException implements Exception {
|
||||
const HiResOriginalRestoreException(this.backupPath, this.cause);
|
||||
|
||||
final String backupPath;
|
||||
final FileSystemException cause;
|
||||
|
||||
@override
|
||||
String toString() => 'Original audio retained at $backupPath: $cause';
|
||||
}
|
||||
|
||||
/// Evidence from the Rust Hi-Res checker's sampled analysis.
|
||||
class HiResCheckResult {
|
||||
final bool supported;
|
||||
final String verdict;
|
||||
final int declaredSampleRate;
|
||||
final double cutoffFrequencyHz;
|
||||
final int declaredBitDepth;
|
||||
final int effectiveBitDepth;
|
||||
final bool paddedBitDepth;
|
||||
final String reason;
|
||||
|
||||
/// For a fake: 'certain', 'likely' or 'suspect' (may be a genuine master
|
||||
/// low-pass filtered in mastering). Empty otherwise.
|
||||
final String confidence;
|
||||
|
||||
/// 'sample_hold', 'linear_interpolation', 'imaging', or empty.
|
||||
final String upsamplingArtifact;
|
||||
|
||||
/// Eligible to try a replacement. Full-file verification is still required.
|
||||
final bool redownloadSafe;
|
||||
|
||||
/// Highest frequency whose level still moves with the music; 0 when not
|
||||
/// measured.
|
||||
final double musicCutoffHz;
|
||||
|
||||
/// True when the content past [musicCutoffHz] is steady noise (a DSD or
|
||||
/// analog tape transfer), so [cutoffFrequencyHz] is where noise ends.
|
||||
final bool ultrasonicNoiseOnly;
|
||||
|
||||
/// With [ultrasonicNoiseOnly]: the lowest standard rate that holds all
|
||||
/// the music. Otherwise the declared rate.
|
||||
final int usefulSampleRate;
|
||||
|
||||
const HiResCheckResult({
|
||||
required this.supported,
|
||||
this.verdict = '',
|
||||
this.declaredSampleRate = 0,
|
||||
this.cutoffFrequencyHz = 0,
|
||||
this.declaredBitDepth = 0,
|
||||
this.effectiveBitDepth = 0,
|
||||
this.paddedBitDepth = false,
|
||||
this.reason = '',
|
||||
this.confidence = '',
|
||||
this.upsamplingArtifact = '',
|
||||
this.redownloadSafe = false,
|
||||
this.musicCutoffHz = 0,
|
||||
this.ultrasonicNoiseOnly = false,
|
||||
this.usefulSampleRate = 0,
|
||||
});
|
||||
|
||||
factory HiResCheckResult.fromJson(Map<String, dynamic> json) {
|
||||
return HiResCheckResult(
|
||||
supported: json['supported'] == true,
|
||||
verdict: json['verdict'] as String? ?? '',
|
||||
declaredSampleRate: (json['declared_sample_rate'] as num?)?.toInt() ?? 0,
|
||||
cutoffFrequencyHz: (json['cutoff_frequency_hz'] as num?)?.toDouble() ?? 0,
|
||||
declaredBitDepth: (json['declared_bit_depth'] as num?)?.toInt() ?? 0,
|
||||
effectiveBitDepth: (json['effective_bit_depth'] as num?)?.toInt() ?? 0,
|
||||
paddedBitDepth: json['padded_bit_depth'] == true,
|
||||
reason: json['reason'] as String? ?? '',
|
||||
confidence: json['confidence'] as String? ?? '',
|
||||
upsamplingArtifact: json['upsampling_artifact'] as String? ?? '',
|
||||
redownloadSafe: json['redownload_safe'] == true,
|
||||
musicCutoffHz: (json['music_cutoff_hz'] as num?)?.toDouble() ?? 0,
|
||||
ultrasonicNoiseOnly: json['ultrasonic_noise_only'] == true,
|
||||
usefulSampleRate: (json['useful_sample_rate'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isFake => verdict == 'fake_hires';
|
||||
|
||||
bool get isCertain => isFake && confidence == 'certain';
|
||||
bool get isSuspect => isFake && confidence == 'suspect';
|
||||
|
||||
/// Mirrors the analysis thresholds: a rate claim above 48 kHz whose content
|
||||
/// stops below 28 kHz. Recomputed here so the reason can be localized.
|
||||
bool get upsampled =>
|
||||
isFake && declaredSampleRate > 48000 && cutoffFrequencyHz < 28000;
|
||||
}
|
||||
|
||||
class HiResCheckService {
|
||||
/// The requested qualities that actually claim Hi-Res. A LOSSLESS request
|
||||
/// answered with CD-range audio is not a fake: it is what was asked for.
|
||||
static const hiResQualities = {'HI_RES', 'HI_RES_LOSSLESS'};
|
||||
|
||||
/// Requested fallback tier. Its name does not guarantee audio equivalence.
|
||||
static const fakeHiResFallbackQuality = 'LOSSLESS';
|
||||
|
||||
static bool isHiResQuality(String quality) =>
|
||||
hiResQualities.contains(quality.trim().toUpperCase());
|
||||
|
||||
/// Runs the check; throws on failure. `supported == false` for formats
|
||||
/// the Rust checker cannot decode (anything but FLAC and PCM WAV).
|
||||
static Future<HiResCheckResult> check(String filePath) async {
|
||||
final json = await PlatformBridge.checkHiResAuthenticity(filePath);
|
||||
final error = json['error'];
|
||||
if (error != null) throw StateError(error.toString());
|
||||
return HiResCheckResult.fromJson(json);
|
||||
}
|
||||
|
||||
/// Every sample, channel and frame must match after removing only zero-bit
|
||||
/// padding or exact sample repetition. Missing/old backend support fails closed.
|
||||
static Future<bool> replacementPreservesAudio(
|
||||
String originalPath,
|
||||
String replacementPath,
|
||||
) async {
|
||||
final result = await PlatformBridge.checkHiResAuthenticity(
|
||||
originalPath,
|
||||
options: {'verify_replacement_path': replacementPath},
|
||||
);
|
||||
return result['error'] == null && result['replacement_equivalent'] == true;
|
||||
}
|
||||
|
||||
/// Keeps a unique backup on the same filesystem until [replaceAndFinalize]
|
||||
/// has validated, processed, published and persisted the replacement.
|
||||
/// False, cancellation or any exception restores the original atomically.
|
||||
static Future<bool> withOriginalBackup(
|
||||
String originalPath,
|
||||
Future<bool> Function(String backupPath) replaceAndFinalize,
|
||||
) async {
|
||||
final original = File(originalPath);
|
||||
final directory = await original.parent.createTemp('.fake-hires-');
|
||||
final backup = File('${directory.path}/${original.uri.pathSegments.last}');
|
||||
var moved = false;
|
||||
var committed = false;
|
||||
try {
|
||||
await original.rename(backup.path);
|
||||
moved = true;
|
||||
committed = await replaceAndFinalize(backup.path);
|
||||
return committed;
|
||||
} finally {
|
||||
if (moved && !committed) {
|
||||
// If restoration fails, retain the backup and surface the error.
|
||||
try {
|
||||
await backup.rename(originalPath);
|
||||
} on FileSystemException catch (error) {
|
||||
throw HiResOriginalRestoreException(backup.path, error);
|
||||
}
|
||||
}
|
||||
// Cleanup failure must not roll back a successfully published file.
|
||||
try {
|
||||
await directory.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// A leftover backup is preferable to losing the completed download.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1156,6 +1156,18 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs the Rust Hi-Res check (spectral cutoff + padded bit depth) on
|
||||
/// a FLAC or WAV file. Returns `{"supported": false}` for other formats.
|
||||
static Future<Map<String, dynamic>> checkHiResAuthenticity(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? options,
|
||||
}) {
|
||||
return _invokeMap('checkHiResAuthenticity', {
|
||||
'file_path': filePath,
|
||||
'options_json': options == null ? '' : jsonEncode(options),
|
||||
});
|
||||
}
|
||||
|
||||
/// Reads the tags and quality fields used for automatic Library display.
|
||||
///
|
||||
/// Android's readAudioMetadata implementation can inspect most SAF files
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/hires_check_service.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
/// On-demand fake Hi-Res check for one FLAC/WAV file: spectral cutoff for
|
||||
/// upsampling, and unused low bits for a 16-bit master padded to 24.
|
||||
class HiResCheckCard extends StatefulWidget {
|
||||
final String filePath;
|
||||
final String? formatHint;
|
||||
|
||||
const HiResCheckCard({super.key, required this.filePath, this.formatHint});
|
||||
|
||||
/// Only FLAC and PCM WAV are decoded by the Go checker.
|
||||
static bool isCandidate(String filePath, String? formatHint) {
|
||||
final format = formatHint?.toLowerCase().trim() ?? '';
|
||||
if (format == 'flac' || format == 'wav') return true;
|
||||
final lower = filePath.toLowerCase();
|
||||
return lower.endsWith('.flac') || lower.endsWith('.wav');
|
||||
}
|
||||
|
||||
@override
|
||||
State<HiResCheckCard> createState() => _HiResCheckCardState();
|
||||
}
|
||||
|
||||
class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
HiResCheckResult? _result;
|
||||
bool _checking = false;
|
||||
String? _error;
|
||||
int _requestId = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HiResCheckCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filePath != widget.filePath) {
|
||||
_requestId++;
|
||||
_result = null;
|
||||
_error = null;
|
||||
_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final requestId = ++_requestId;
|
||||
setState(() {
|
||||
_checking = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
HiResCheckResult? result;
|
||||
try {
|
||||
result = await HiResCheckService.check(widget.filePath);
|
||||
} on StateError catch (e) {
|
||||
error = e.message;
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
if (!mounted || requestId != _requestId) return;
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_result = result;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!HiResCheckCard.isCandidate(widget.filePath, widget.formatHint)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
final result = _result;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.verified_outlined, color: cs.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.hiResCheckTitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_checking)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
else if (result != null || _error != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: l10n.audioAnalysisRescan,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 48,
|
||||
minHeight: 48,
|
||||
),
|
||||
color: cs.onSurfaceVariant,
|
||||
onPressed: _check,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_checking)
|
||||
_secondaryText(l10n.hiResCheckChecking, cs)
|
||||
else if (_error != null)
|
||||
Text(
|
||||
l10n.hiResCheckFailed(_error!),
|
||||
style: TextStyle(color: cs.error, fontSize: 13),
|
||||
)
|
||||
else if (result == null) ...[
|
||||
_secondaryText(l10n.hiResCheckDescription, cs),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _check,
|
||||
icon: const Icon(Icons.search, size: 18),
|
||||
label: Text(l10n.hiResCheckRun),
|
||||
),
|
||||
] else if (!result.supported)
|
||||
_secondaryText(l10n.hiResCheckUnsupported, cs)
|
||||
else
|
||||
..._buildResult(context, result, cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _secondaryText(String text, ColorScheme cs) {
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildResult(
|
||||
BuildContext context,
|
||||
HiResCheckResult result,
|
||||
ColorScheme cs,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final (IconData icon, Color color, String label) = switch (result.verdict) {
|
||||
'fake_hires' when result.isCertain => (
|
||||
Icons.error_outline,
|
||||
cs.error,
|
||||
l10n.hiResCheckVerdictFakeCertain,
|
||||
),
|
||||
'fake_hires' when result.isSuspect => (
|
||||
Icons.help_outline,
|
||||
cs.tertiary,
|
||||
l10n.hiResCheckVerdictFakeSuspect,
|
||||
),
|
||||
'fake_hires' => (
|
||||
Icons.warning_amber_rounded,
|
||||
cs.error,
|
||||
l10n.hiResCheckVerdictFake,
|
||||
),
|
||||
'genuine_hires' => (
|
||||
Icons.check_circle_outline,
|
||||
cs.primary,
|
||||
l10n.hiResCheckVerdictGenuine,
|
||||
),
|
||||
'standard_definition' => (
|
||||
Icons.info_outline,
|
||||
cs.onSurfaceVariant,
|
||||
l10n.hiResCheckVerdictStandard,
|
||||
),
|
||||
_ => (
|
||||
Icons.help_outline,
|
||||
cs.onSurfaceVariant,
|
||||
l10n.hiResCheckVerdictInconclusive,
|
||||
),
|
||||
};
|
||||
|
||||
final effectiveBits = result.effectiveBitDepth > 0
|
||||
? result.effectiveBitDepth
|
||||
: null;
|
||||
final reasons = <String>[
|
||||
if (result.upsamplingArtifact == 'sample_hold')
|
||||
l10n.hiResCheckReasonSampleHold,
|
||||
if (result.upsamplingArtifact == 'linear_interpolation')
|
||||
l10n.hiResCheckReasonInterpolation,
|
||||
if (result.upsamplingArtifact == 'imaging') l10n.hiResCheckReasonImaging,
|
||||
if (result.upsampled)
|
||||
l10n.hiResCheckReasonRate(
|
||||
_formatKHz(result.declaredSampleRate.toDouble()),
|
||||
_formatKHz(result.cutoffFrequencyHz),
|
||||
),
|
||||
if (result.isFake && result.paddedBitDepth)
|
||||
l10n.hiResCheckReasonDepth(
|
||||
result.declaredBitDepth,
|
||||
result.effectiveBitDepth,
|
||||
),
|
||||
];
|
||||
|
||||
return [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final reason in reasons) ...[
|
||||
const SizedBox(height: 4),
|
||||
_secondaryText('• $reason', cs),
|
||||
],
|
||||
if (result.ultrasonicNoiseOnly) ...[
|
||||
const SizedBox(height: 4),
|
||||
_secondaryText(
|
||||
'• ${l10n.hiResCheckUltrasonicNoise(_formatKHz(result.musicCutoffHz), _formatKHz(result.usefulSampleRate.toDouble()))}',
|
||||
cs,
|
||||
),
|
||||
],
|
||||
if (result.verdict != 'inconclusive') ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
// Past the music only steady noise remains, so where it ends
|
||||
// says nothing about the music: show the music's own edge.
|
||||
if (result.ultrasonicNoiseOnly)
|
||||
_detail(
|
||||
l10n.hiResCheckMusicContent,
|
||||
'~${_formatKHz(result.musicCutoffHz)}',
|
||||
cs,
|
||||
)
|
||||
else
|
||||
_detail(
|
||||
l10n.hiResCheckCutoff,
|
||||
'~${_formatKHz(result.cutoffFrequencyHz)}',
|
||||
cs,
|
||||
),
|
||||
if (result.declaredBitDepth > 0)
|
||||
_detail(
|
||||
l10n.hiResCheckBitsInUse,
|
||||
'${effectiveBits ?? '?'} / ${result.declaredBitDepth}-bit',
|
||||
cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (result.isSuspect) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.hiResCheckDisclaimer,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _detail(String label, String value, ColorScheme cs) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$label: ',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatKHz(double hz) => '${(hz / 1000).toStringAsFixed(1)} kHz';
|
||||
}
|
||||
Reference in new issue
Block a user