Files
SpotiFLAC-Mobile/lib/widgets/audio_analysis_info_card.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

312 lines
11 KiB
Dart

part of 'audio_analysis_widget.dart';
// Read-only info card with metric chips for a finished analysis.
class _AudioInfoCard extends StatelessWidget {
final AudioAnalysisData data;
final VoidCallback? onRescan;
const _AudioInfoCard({required this.data, this.onRescan});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final nyquist = data.sampleRate / 2;
return AppContentCard(
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.analytics_outlined, color: cs.primary, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.audioAnalysisTitle,
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
if (onRescan != null)
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: context.l10n.audioAnalysisRescan,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
color: cs.onSurfaceVariant,
onPressed: onRescan,
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
if (data.codec.isNotEmpty)
_MetricChip(
icon: Icons.memory,
label: context.l10n.audioAnalysisCodec,
value: data.codec,
cs: cs,
),
if (data.container.isNotEmpty)
_MetricChip(
icon: Icons.inventory_2_outlined,
label: context.l10n.audioAnalysisContainer,
value: data.container,
cs: cs,
),
_MetricChip(
icon: Icons.graphic_eq,
label: context.l10n.audioAnalysisSampleRate,
value: '${(data.sampleRate / 1000).toStringAsFixed(1)} kHz',
cs: cs,
),
_MetricChip(
icon: Icons.audio_file,
label: context.l10n.audioAnalysisBitDepth,
value: data.bitDepth,
cs: cs,
),
if (data.decodedSampleFormat.isNotEmpty)
_MetricChip(
icon: Icons.data_object,
label: context.l10n.audioAnalysisDecodedFormat,
value: data.decodedSampleFormat,
cs: cs,
),
if (data.bitrate > 0)
_MetricChip(
icon: Icons.speed,
label: context.l10n.trackConvertBitrate,
value: _formatBitrate(data.bitrate),
cs: cs,
),
_MetricChip(
icon: Icons.surround_sound,
label: context.l10n.audioAnalysisChannels,
value: _formatChannels(context, data),
cs: cs,
),
_MetricChip(
icon: Icons.timer_outlined,
label: context.l10n.audioAnalysisDuration,
value: formatClock(data.duration),
cs: cs,
),
_MetricChip(
icon: Icons.multiline_chart,
label: context.l10n.audioAnalysisNyquist,
value: '${(nyquist / 1000).toStringAsFixed(1)} kHz',
cs: cs,
),
if (data.fileSize > 0)
_MetricChip(
icon: Icons.storage,
label: context.l10n.audioAnalysisFileSize,
value: formatBytes(data.fileSize),
cs: cs,
),
],
),
const SizedBox(height: 8),
Divider(color: cs.outlineVariant),
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_MetricChip(
icon: Icons.trending_up,
label: context.l10n.audioAnalysisDynamicRange,
value: '${data.dynamicRange.toStringAsFixed(2)} dB',
cs: cs,
),
_MetricChip(
icon: Icons.show_chart,
label: context.l10n.audioAnalysisPeak,
value: '${data.peakAmplitude.toStringAsFixed(2)} dB',
cs: cs,
),
_MetricChip(
icon: Icons.equalizer,
label: context.l10n.audioAnalysisRms,
value: '${data.rmsLevel.toStringAsFixed(2)} dB',
cs: cs,
),
if (data.integratedLufs != null)
_MetricChip(
icon: Icons.volume_up_outlined,
label: context.l10n.audioAnalysisLufs,
value: '${data.integratedLufs!.toStringAsFixed(1)} LUFS',
cs: cs,
),
if (data.truePeakDb != null)
_MetricChip(
icon: Icons.warning_amber_outlined,
label: context.l10n.audioAnalysisTruePeak,
value: '${data.truePeakDb!.toStringAsFixed(2)} dBTP',
cs: cs,
),
_MetricChip(
icon: Icons.report_gmailerrorred_outlined,
label: context.l10n.audioAnalysisClipping,
value: _formatClipping(context, data.clippingSamples),
cs: cs,
),
_MetricChip(
icon: Icons.filter_alt_outlined,
label: context.l10n.audioAnalysisSpectralCutoff,
value: formatAudioAnalysisSpectralCutoff(
data.spectralCutoffHz,
notDetectedLabel:
context.l10n.audioAnalysisCutoffNotDetected,
),
cs: cs,
),
_MetricChip(
icon: Icons.numbers,
label: context.l10n.audioAnalysisSamples,
value: _formatNumber(data.totalSamples),
cs: cs,
),
],
),
if (data.channelStats.length > 1) ...[
const SizedBox(height: 8),
Divider(color: cs.outlineVariant),
const SizedBox(height: 8),
Text(
context.l10n.audioAnalysisChannelStats,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 8,
children: data.channelStats.map((stats) {
return _MetricChip(
icon: Icons.surround_sound,
label: 'Ch ${stats.channel}',
value: _formatChannelStats(stats),
cs: cs,
);
}).toList(),
),
],
],
),
),
);
}
String _formatChannels(BuildContext context, AudioAnalysisData data) {
final layout = data.channelLayout.trim();
if (layout.isNotEmpty && layout != 'unknown') {
return data.channels > 0 ? '${data.channels} ($layout)' : layout;
}
if (data.channels == 2) return context.l10n.audioAnalysisStereo;
if (data.channels == 1) return context.l10n.audioAnalysisMono;
return data.channels > 0 ? '${data.channels}' : 'N/A';
}
String _formatBitrate(int bitsPerSecond) {
if (bitsPerSecond >= 1000000) {
return '${(bitsPerSecond / 1000000).toStringAsFixed(2)} Mbps';
}
return '${(bitsPerSecond / 1000).round()} kbps';
}
String _formatClipping(BuildContext context, int samples) {
if (samples <= 0) return context.l10n.audioAnalysisNoClipping;
return _formatNumber(samples);
}
String _formatChannelStats(ChannelAnalysisStats stats) {
final parts = <String>[];
if (stats.peakDb != null) {
parts.add('P ${stats.peakDb!.toStringAsFixed(1)}');
}
if (stats.rmsDb != null) {
parts.add('R ${stats.rmsDb!.toStringAsFixed(1)}');
}
if (stats.dynamicRangeDb != null) {
parts.add('DR ${stats.dynamicRangeDb!.toStringAsFixed(1)}');
}
if (stats.peakCount > 0 && (stats.peakDb ?? -100) >= -0.1) {
parts.add('Clip ${_formatNumber(stats.peakCount)}');
}
return parts.isEmpty ? 'N/A' : parts.join(' / ');
}
String _formatNumber(int n) {
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K';
return n.toString();
}
}
class _MetricChip extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final ColorScheme cs;
const _MetricChip({
required this.icon,
required this.label,
required this.value,
required this.cs,
});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: cs.onSurfaceVariant),
const SizedBox(width: 4),
Text(
'$label: ',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
Flexible(
child: Text(
value,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}