mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
fix: adapt Hi-Res analysis card to Mornye and clarify results
This commit is contained in:
1 parent
1bae8156bb
commit
3be9f40380
5 files changed
+404
-86
No files matched your search
@@ -4936,6 +4936,61 @@
|
||||
"@hiResCheckTitle": {
|
||||
"description": "Title of the fake Hi-Res check card"
|
||||
},
|
||||
"hiResCheckSummary": "Inspect the spectrum and active bit depth for signs of upsampling or padding.",
|
||||
"@hiResCheckSummary": {
|
||||
"description": "Introductory text for the sampled Hi-Res analysis"
|
||||
},
|
||||
"hiResCheckLimitedBandwidth": "Limited bandwidth",
|
||||
"@hiResCheckLimitedBandwidth": {
|
||||
"description": "Neutral finding: a limited spectrum alone does not prove upsampling"
|
||||
},
|
||||
"hiResCheckNoEvidence": "No upsampling or padding detected",
|
||||
"@hiResCheckNoEvidence": {
|
||||
"description": "No evidence was detected; this does not certify the master"
|
||||
},
|
||||
"hiResCheckEstimatedCutoff": "Estimated cutoff",
|
||||
"@hiResCheckEstimatedCutoff": {
|
||||
"description": "Estimated upper spectral edge at the analysis threshold"
|
||||
},
|
||||
"hiResCheckActiveBits": "Active / stored bits",
|
||||
"@hiResCheckActiveBits": {
|
||||
"description": "Active integer bit depth versus the container depth in the sampled segment"
|
||||
},
|
||||
"hiResCheckBandwidthExplanation": "A lower cutoff can come from mastering. It does not, by itself, prove upsampling.",
|
||||
"@hiResCheckBandwidthExplanation": {
|
||||
"description": "Explains why the limited-bandwidth finding is neutral"
|
||||
},
|
||||
"hiResCheckSampledExplanation": "Based on {seconds} seconds of audio. This analysis cannot certify the original master.",
|
||||
"@hiResCheckSampledExplanation": {
|
||||
"description": "Limits of the sampled Hi-Res analysis; seconds is a formatted duration",
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckImagingEvidence": "The spectrum contains mirrored bands consistent with upsampling.",
|
||||
"@hiResCheckImagingEvidence": {
|
||||
"description": "Spectral imaging is suggestive evidence rather than proof"
|
||||
},
|
||||
"hiResCheckRateEvidence": "A sharp roll-off near {cutoff} and the measured noise floor suggest a lower-rate source.",
|
||||
"@hiResCheckRateEvidence": {
|
||||
"description": "Heuristic evidence from both a brickwall and a 16-bit-like noise floor",
|
||||
"placeholders": {
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckSteadyEnergy": "Energy above {cutoff} is mostly steady in the analyzed segment and may be noise.",
|
||||
"@hiResCheckSteadyEnergy": {
|
||||
"description": "An observation about ultrasonic energy, not proof that removing it is safe",
|
||||
"placeholders": {
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckDescription": "Detect upsampled or bit-padded fake Hi-Res files",
|
||||
"@hiResCheckDescription": {
|
||||
"description": "Explains what the Hi-Res authenticity check does"
|
||||
|
||||
+14
-1
@@ -6324,5 +6324,18 @@
|
||||
"profileChangePhoto": "Pilih foto",
|
||||
"profileRemovePhoto": "Hapus foto",
|
||||
"profilePhotoError": "Pilih gambar yang valid dengan ukuran kurang dari 20 MB.",
|
||||
"profileSaveError": "Profil belum berhasil disimpan. Silakan coba lagi."
|
||||
"profileSaveError": "Profil belum berhasil disimpan. Silakan coba lagi.",
|
||||
"hiResCheckTitle": "Keaslian Hi-Res",
|
||||
"hiResCheckSummary": "Periksa spektrum dan kedalaman bit aktif untuk menemukan tanda upsampling atau padding.",
|
||||
"hiResCheckRun": "Periksa",
|
||||
"hiResCheckChecking": "Memeriksa audio Hi-Res...",
|
||||
"hiResCheckLimitedBandwidth": "Rentang frekuensi terbatas",
|
||||
"hiResCheckNoEvidence": "Tidak terdeteksi upsampling atau padding",
|
||||
"hiResCheckEstimatedCutoff": "Perkiraan cutoff",
|
||||
"hiResCheckActiveBits": "Bit aktif / tersimpan",
|
||||
"hiResCheckBandwidthExplanation": "Cutoff yang lebih rendah bisa berasal dari proses mastering. Hal ini saja tidak membuktikan adanya upsampling.",
|
||||
"hiResCheckSampledExplanation": "Berdasarkan {seconds} detik audio. Analisis ini tidak dapat memastikan keaslian master sumber.",
|
||||
"hiResCheckImagingEvidence": "Spektrum memiliki pola pita tercermin yang konsisten dengan upsampling.",
|
||||
"hiResCheckRateEvidence": "Penurunan tajam di sekitar {cutoff} dan noise floor yang terukur mengindikasikan sumber dengan sample rate lebih rendah.",
|
||||
"hiResCheckSteadyEnergy": "Energi di atas {cutoff} relatif konstan pada bagian yang dianalisis dan mungkin berupa noise."
|
||||
}
|
||||
@@ -18,6 +18,8 @@ class HiResCheckResult {
|
||||
final String verdict;
|
||||
final int declaredSampleRate;
|
||||
final double cutoffFrequencyHz;
|
||||
final double analyzedDurationSeconds;
|
||||
final double brickwallHz;
|
||||
final int declaredBitDepth;
|
||||
final int effectiveBitDepth;
|
||||
final bool paddedBitDepth;
|
||||
@@ -50,6 +52,8 @@ class HiResCheckResult {
|
||||
this.verdict = '',
|
||||
this.declaredSampleRate = 0,
|
||||
this.cutoffFrequencyHz = 0,
|
||||
this.analyzedDurationSeconds = 0,
|
||||
this.brickwallHz = 0,
|
||||
this.declaredBitDepth = 0,
|
||||
this.effectiveBitDepth = 0,
|
||||
this.paddedBitDepth = false,
|
||||
@@ -68,6 +72,9 @@ class HiResCheckResult {
|
||||
verdict: json['verdict'] as String? ?? '',
|
||||
declaredSampleRate: (json['declared_sample_rate'] as num?)?.toInt() ?? 0,
|
||||
cutoffFrequencyHz: (json['cutoff_frequency_hz'] as num?)?.toDouble() ?? 0,
|
||||
analyzedDurationSeconds:
|
||||
(json['analyzed_duration_s'] as num?)?.toDouble() ?? 0,
|
||||
brickwallHz: (json['brickwall_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,
|
||||
@@ -86,10 +93,9 @@ class HiResCheckResult {
|
||||
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;
|
||||
/// Older backends called a limited spectrum "suspect". Neither that label
|
||||
/// nor the cutoff alone establishes that the file was upsampled.
|
||||
bool get hasLimitedBandwidth => verdict == 'band_limited' || isSuspect;
|
||||
}
|
||||
|
||||
class HiResCheckService {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
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/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/app_content_card.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.
|
||||
/// On-demand sampled spectrum and bit-depth analysis for one FLAC/WAV file.
|
||||
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.
|
||||
/// Only FLAC and PCM WAV are decoded by the Rust checker.
|
||||
static bool isCandidate(String filePath, String? formatHint) {
|
||||
final format = formatHint?.toLowerCase().trim() ?? '';
|
||||
if (format == 'flac' || format == 'wav') return true;
|
||||
@@ -71,8 +75,9 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
final result = _result;
|
||||
final mornye = context.isMornye;
|
||||
|
||||
return Card(
|
||||
return AppContentCard(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -80,29 +85,47 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(mornye ? 20 : 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.verified_outlined, color: cs.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
if (!mornye) ...[
|
||||
Icon(Icons.graphic_eq, color: cs.onSurfaceVariant, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.hiResCheckTitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
fontSize: 17,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_checking)
|
||||
const SizedBox(
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
child: mornye
|
||||
? const CupertinoActivityIndicator()
|
||||
: const CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
else if (mornye && (result != null || _error != null))
|
||||
Tooltip(
|
||||
message: l10n.audioAnalysisRescan,
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.all(12),
|
||||
onPressed: _check,
|
||||
child: Icon(
|
||||
CupertinoIcons.refresh,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
semanticLabel: l10n.audioAnalysisRescan,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (result != null || _error != null)
|
||||
IconButton(
|
||||
@@ -125,15 +148,26 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
else if (_error != null)
|
||||
Text(
|
||||
l10n.hiResCheckFailed(_error!),
|
||||
style: TextStyle(color: cs.error, fontSize: 13),
|
||||
style: TextStyle(color: cs.error, fontSize: 15),
|
||||
)
|
||||
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),
|
||||
_secondaryText(l10n.hiResCheckSummary, cs),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: mornye
|
||||
? AppActionButton(
|
||||
outlined: true,
|
||||
tonal: true,
|
||||
onPressed: _check,
|
||||
icon: const Icon(Icons.search),
|
||||
label: Text(l10n.hiResCheckRun),
|
||||
)
|
||||
: FilledButton.tonalIcon(
|
||||
onPressed: _check,
|
||||
icon: const Icon(Icons.search, size: 20),
|
||||
label: Text(l10n.hiResCheckRun),
|
||||
),
|
||||
),
|
||||
] else if (!result.supported)
|
||||
_secondaryText(l10n.hiResCheckUnsupported, cs)
|
||||
@@ -148,7 +182,7 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
Widget _secondaryText(String text, ColorScheme cs) {
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15, height: 1.35),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,10 +198,10 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
cs.error,
|
||||
l10n.hiResCheckVerdictFakeCertain,
|
||||
),
|
||||
'fake_hires' when result.isSuspect => (
|
||||
Icons.help_outline,
|
||||
cs.tertiary,
|
||||
l10n.hiResCheckVerdictFakeSuspect,
|
||||
_ when result.hasLimitedBandwidth => (
|
||||
Icons.info_outline,
|
||||
cs.onSurface,
|
||||
l10n.hiResCheckLimitedBandwidth,
|
||||
),
|
||||
'fake_hires' => (
|
||||
Icons.warning_amber_rounded,
|
||||
@@ -176,8 +210,8 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
),
|
||||
'genuine_hires' => (
|
||||
Icons.check_circle_outline,
|
||||
cs.primary,
|
||||
l10n.hiResCheckVerdictGenuine,
|
||||
cs.onSurface,
|
||||
l10n.hiResCheckNoEvidence,
|
||||
),
|
||||
'standard_definition' => (
|
||||
Icons.info_outline,
|
||||
@@ -199,12 +233,13 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
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.upsamplingArtifact == 'imaging')
|
||||
l10n.hiResCheckImagingEvidence,
|
||||
if (result.isFake &&
|
||||
result.confidence == 'likely' &&
|
||||
result.upsamplingArtifact.isEmpty &&
|
||||
result.brickwallHz > 0)
|
||||
l10n.hiResCheckRateEvidence(_formatKHz(result.brickwallHz)),
|
||||
if (result.isFake && result.paddedBitDepth)
|
||||
l10n.hiResCheckReasonDepth(
|
||||
result.declaredBitDepth,
|
||||
@@ -215,88 +250,95 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
return [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Icon(context.adaptiveIcon(icon), color: color, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final reason in reasons) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 10),
|
||||
_secondaryText('• $reason', cs),
|
||||
],
|
||||
if (result.ultrasonicNoiseOnly) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 10),
|
||||
_secondaryText(
|
||||
'• ${l10n.hiResCheckUltrasonicNoise(_formatKHz(result.musicCutoffHz), _formatKHz(result.usefulSampleRate.toDouble()))}',
|
||||
l10n.hiResCheckSteadyEnergy(_formatKHz(result.musicCutoffHz)),
|
||||
cs,
|
||||
),
|
||||
],
|
||||
if (result.verdict != 'inconclusive') ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
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,
|
||||
),
|
||||
],
|
||||
_detail(
|
||||
l10n.audioAnalysisSampleRate,
|
||||
_formatKHz(result.declaredSampleRate.toDouble()),
|
||||
cs,
|
||||
),
|
||||
],
|
||||
if (result.isSuspect) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.hiResCheckDisclaimer,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
_detail(
|
||||
l10n.audioAnalysisNyquist,
|
||||
_formatKHz(result.declaredSampleRate / 2),
|
||||
cs,
|
||||
),
|
||||
_detail(
|
||||
l10n.hiResCheckEstimatedCutoff,
|
||||
'~${_formatKHz(result.cutoffFrequencyHz)}',
|
||||
cs,
|
||||
),
|
||||
if (result.declaredBitDepth > 0)
|
||||
_detail(
|
||||
l10n.hiResCheckActiveBits,
|
||||
'${effectiveBits ?? '?'} / ${result.declaredBitDepth}',
|
||||
cs,
|
||||
),
|
||||
],
|
||||
if (result.hasLimitedBandwidth) ...[
|
||||
const SizedBox(height: 16),
|
||||
_secondaryText(l10n.hiResCheckBandwidthExplanation, cs),
|
||||
],
|
||||
if (result.analyzedDurationSeconds > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
_secondaryText(
|
||||
l10n.hiResCheckSampledExplanation(
|
||||
result.analyzedDurationSeconds.toStringAsFixed(1),
|
||||
),
|
||||
cs,
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _detail(String label, String value, ColorScheme cs) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$label: ',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/hires_check_card.dart';
|
||||
|
||||
const _limitedResult = <String, dynamic>{
|
||||
'supported': true,
|
||||
'verdict': 'band_limited',
|
||||
'declared_sample_rate': 96000,
|
||||
'cutoff_frequency_hz': 27164.0625,
|
||||
'declared_bit_depth': 24,
|
||||
'effective_bit_depth': 24,
|
||||
'analyzed_duration_s': 30,
|
||||
};
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final capture = GlobalKey();
|
||||
var calls = 0;
|
||||
|
||||
setUpAll(() async {
|
||||
for (final (family, asset) in [
|
||||
('MaterialIcons', 'fonts/MaterialIcons-Regular.otf'),
|
||||
('Google Sans Flex', 'assets/fonts/GoogleSansFlex.ttf'),
|
||||
('Inter', 'assets/fonts/InterVariable.ttf'),
|
||||
(
|
||||
'packages/cupertino_icons/CupertinoIcons',
|
||||
'packages/cupertino_icons/assets/CupertinoIcons.ttf',
|
||||
),
|
||||
]) {
|
||||
await (FontLoader(family)..addFont(rootBundle.load(asset))).load();
|
||||
}
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
calls = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'checkHiResAuthenticity');
|
||||
calls++;
|
||||
return _limitedResult;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
Future<void> openCard(
|
||||
WidgetTester tester, {
|
||||
bool mornye = true,
|
||||
Brightness brightness = Brightness.light,
|
||||
double textScale = 1,
|
||||
String path = '/music/track.flac',
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: mornye
|
||||
? MornyeTheme.build(brightness)
|
||||
: brightness == Brightness.light
|
||||
? AppTheme.light()
|
||||
: AppTheme.dark(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('en'),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: TextScaler.linear(textScale)),
|
||||
child: RepaintBoundary(key: capture, child: child),
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: HiResCheckCard(filePath: path),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> captureCard(WidgetTester tester, String name) async {
|
||||
if (!const bool.fromEnvironment('CAPTURE_HIRES_CARD')) return;
|
||||
await tester.runAsync(() async {
|
||||
final boundary =
|
||||
capture.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final image = await boundary.toImage(pixelRatio: 2);
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
await File(
|
||||
'/tmp/spotiflac-hires-$name.png',
|
||||
).writeAsBytes(data!.buffer.asUint8List());
|
||||
image.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
for (final mornye in [true, false]) {
|
||||
for (final brightness in Brightness.values) {
|
||||
final name = '${mornye ? 'mornye' : 'material'}-${brightness.name}';
|
||||
testWidgets('centered action and neutral bandwidth result ($name)', (
|
||||
tester,
|
||||
) async {
|
||||
await openCard(tester, mornye: mornye, brightness: brightness);
|
||||
await tester.pumpAndSettle();
|
||||
final button = find.ancestor(
|
||||
of: find.text('Check'),
|
||||
matching: mornye
|
||||
? find.byType(CupertinoButton)
|
||||
: find.bySubtype<FilledButton>(),
|
||||
);
|
||||
expect(button, findsOneWidget);
|
||||
expect(tester.getSize(button).width, greaterThan(300));
|
||||
expect(tester.getCenter(button).dx, closeTo(195, 1));
|
||||
expect(find.byType(Card), mornye ? findsNothing : findsOneWidget);
|
||||
await captureCard(tester, '$name-idle');
|
||||
|
||||
await tester.tap(find.text('Check'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 1);
|
||||
expect(find.text('Limited bandwidth'), findsOneWidget);
|
||||
expect(find.text('Fake Hi-Res'), findsNothing);
|
||||
expect(find.text('96.0 kHz'), findsOneWidget);
|
||||
expect(find.text('48.0 kHz'), findsOneWidget);
|
||||
expect(find.text('~27.2 kHz'), findsOneWidget);
|
||||
expect(find.text('24 / 24'), findsOneWidget);
|
||||
final rowValue = tester.getRect(find.text('~27.2 kHz'));
|
||||
final rowLabel = tester.getRect(find.text('Estimated cutoff'));
|
||||
expect(rowValue.top, closeTo(rowLabel.top, 1));
|
||||
expect(rowValue.left, greaterThan(rowLabel.right));
|
||||
expect(rowValue.right, closeTo(354, 1));
|
||||
expect(tester.takeException(), isNull);
|
||||
await captureCard(tester, '$name-result');
|
||||
await tester.tap(find.byTooltip('Re-analyze'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (final mornye in [true, false]) {
|
||||
testWidgets('large text stays readable ($mornye)', (tester) async {
|
||||
await openCard(tester, mornye: mornye, textScale: 2);
|
||||
await tester.tap(find.text('Check'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.textContaining('Based on 30.0 seconds'),
|
||||
180,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'a result from the previous track cannot overwrite the new track',
|
||||
(tester) async {
|
||||
final pending = Completer<Map<String, dynamic>>();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) => pending.future);
|
||||
await openCard(tester);
|
||||
await tester.tap(find.text('Check'));
|
||||
await tester.pump();
|
||||
await openCard(tester, path: '/music/another.flac');
|
||||
pending.complete(_limitedResult);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Check'), findsOneWidget);
|
||||
expect(find.text('Limited bandwidth'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('legacy suspect results are not presented as proven fakes', (
|
||||
tester,
|
||||
) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
channel,
|
||||
(_) async => {
|
||||
..._limitedResult,
|
||||
'verdict': 'fake_hires',
|
||||
'confidence': 'suspect',
|
||||
},
|
||||
);
|
||||
await openCard(tester);
|
||||
await tester.tap(find.text('Check'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Limited bandwidth'), findsOneWidget);
|
||||
expect(find.textContaining('Declares'), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user