mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
The month-year DateFormat was rebuilt for every loaded row and every fast-scroller drag label; it is now cached per locale tag. Constant-pattern RegExp objects built per call (or per word) are now static or top-level.
682 lines
23 KiB
Dart
682 lines
23 KiB
Dart
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/cupertino.dart' show CupertinoButton;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
|
||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||
import 'package:spotiflac_android/models/equalizer_settings.dart';
|
||
import 'package:spotiflac_android/models/settings.dart';
|
||
import 'package:spotiflac_android/services/audio_route_info.dart';
|
||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||
import 'package:spotiflac_android/services/playback_equalizer.dart';
|
||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||
import 'package:spotiflac_android/widgets/app_icon.dart';
|
||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||
import 'package:spotiflac_android/widgets/expressive_button.dart';
|
||
import 'package:spotiflac_android/widgets/hires_check_card.dart';
|
||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||
|
||
/// Technical details of the playing track, from the file through the
|
||
/// processing chain to the output device.
|
||
Future<void> showAudioInfoSheet(
|
||
BuildContext context, {
|
||
required PlayerMetadataController metadata,
|
||
}) => showAppScrollableBottomSheet<void>(
|
||
context: context,
|
||
title: context.l10n.audioInfoTitle,
|
||
subtitle: metadata.qualityLabel,
|
||
builder: (context, controller) =>
|
||
_AudioInfoSheet(metadata: metadata, controller: controller),
|
||
);
|
||
|
||
class _AudioInfoSheet extends StatefulWidget {
|
||
const _AudioInfoSheet({required this.metadata, required this.controller});
|
||
|
||
final PlayerMetadataController metadata;
|
||
final ScrollController controller;
|
||
|
||
@override
|
||
State<_AudioInfoSheet> createState() => _AudioInfoSheetState();
|
||
}
|
||
|
||
class _AudioInfoSheetState extends State<_AudioInfoSheet> {
|
||
AudioRouteInfo? _route;
|
||
int? _fileSize;
|
||
String? _loadedPath;
|
||
int _loadGeneration = 0;
|
||
bool _loading = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
widget.metadata.addListener(_load);
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
widget.metadata.removeListener(_load);
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
final path = widget.metadata.filePath;
|
||
if (path == _loadedPath && (_loading || _route != null)) return;
|
||
final generation = ++_loadGeneration;
|
||
_loadedPath = path;
|
||
_loading = true;
|
||
_route = null;
|
||
_fileSize = null;
|
||
final results = await Future.wait<Object?>([
|
||
readAudioRouteInfo(path: path),
|
||
_sizeOf(path),
|
||
]);
|
||
if (!mounted || generation != _loadGeneration) return;
|
||
setState(() {
|
||
_loading = false;
|
||
_route = results[0] as AudioRouteInfo?;
|
||
_fileSize = results[1] as int?;
|
||
});
|
||
}
|
||
|
||
static Future<int?> _sizeOf(String? path) async {
|
||
if (path == null || path.isEmpty) return null;
|
||
try {
|
||
if (path.startsWith('content://')) {
|
||
final size = (await PlatformBridge.safStat(path))['size'];
|
||
return size is num && size > 0 ? size.toInt() : null;
|
||
}
|
||
if (!path.startsWith('/')) return null;
|
||
return await File(path).length();
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => ListenableBuilder(
|
||
listenable: Listenable.merge([
|
||
widget.metadata,
|
||
usbAudioStatus,
|
||
playbackEqualizer,
|
||
]),
|
||
builder: (context, _) {
|
||
final facts = _AudioFacts(
|
||
l10n: context.l10n,
|
||
metadata: widget.metadata.metadata ?? const {},
|
||
path: widget.metadata.filePath,
|
||
fileSize: _fileSize,
|
||
route: _route,
|
||
usb: usbAudioStatus.value,
|
||
equalizer: playbackEqualizer.value,
|
||
processing: musicPlayerRuntime.handler?.processing,
|
||
settings: musicPlayerRuntime.settings,
|
||
);
|
||
final sections = facts.sections();
|
||
final path = widget.metadata.filePath;
|
||
final analyzable =
|
||
path != null &&
|
||
path.isNotEmpty &&
|
||
!const {'http', 'https'}.contains(Uri.tryParse(path)?.scheme);
|
||
final format = widget.metadata.metadata?['format']?.toString();
|
||
return SingleChildScrollView(
|
||
controller: widget.controller,
|
||
padding: EdgeInsets.only(bottom: context.tokens.gapLg),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
for (final section in sections) ...[
|
||
SettingsSectionHeader(title: section.title),
|
||
_FactGroup(
|
||
children: [
|
||
for (final (index, fact) in section.facts.indexed)
|
||
_FactRow(
|
||
fact: fact,
|
||
last: index == section.facts.length - 1,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
if (analyzable) ...[
|
||
SettingsSectionHeader(title: context.l10n.audioInfoAnalysis),
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: context.tokens.gapLg),
|
||
child: Column(
|
||
children: [
|
||
AudioAnalysisCard(filePath: path, codecHint: format),
|
||
if (HiResCheckCard.isCandidate(path, format)) ...[
|
||
SizedBox(height: context.tokens.gapLg),
|
||
HiResCheckCard(filePath: path, formatHint: format),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
SizedBox(height: context.tokens.gapLg),
|
||
if (context.isMornye)
|
||
// An iOS action cell: tinted text in the same grouped surface.
|
||
_FactGroup(
|
||
children: [
|
||
CupertinoButton(
|
||
minimumSize: Size.square(context.tokens.minTouchTarget),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
onPressed: () => _copy(context, sections),
|
||
child: Text(
|
||
context.l10n.audioInfoCopy,
|
||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
Center(
|
||
child: ExpressiveButton(
|
||
tonal: true,
|
||
icon: const AppIcon(Icons.copy_rounded),
|
||
onPressed: () => _copy(context, sections),
|
||
child: Text(context.l10n.audioInfoCopy),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
Future<void> _copy(BuildContext context, List<_Section> sections) async {
|
||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||
final copied = context.l10n.audioInfoCopied;
|
||
final report = StringBuffer(widget.metadata.qualityLabel ?? '');
|
||
for (final section in sections) {
|
||
report
|
||
..writeln()
|
||
..writeln()
|
||
..writeln(section.title);
|
||
for (final fact in section.facts) {
|
||
report.writeln('${fact.label}: ${fact.value}');
|
||
}
|
||
}
|
||
await Clipboard.setData(ClipboardData(text: report.toString().trim()));
|
||
messenger?.showSnackBar(SnackBar(content: Text(copied)));
|
||
}
|
||
}
|
||
|
||
/// Mornye sheets are glass, so a settings cell color would vanish into them;
|
||
/// rows use the translucent control fill, like the track details page.
|
||
class _FactGroup extends StatelessWidget {
|
||
const _FactGroup({required this.children});
|
||
|
||
final List<Widget> children;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (!context.isMornye) {
|
||
return SettingsGroup(connected: true, children: children);
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||
child: Material(
|
||
color: MornyeTheme.controlFill(context),
|
||
borderRadius: BorderRadius.circular(28),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: children),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FactRow extends StatelessWidget {
|
||
const _FactRow({required this.fact, required this.last});
|
||
|
||
final _Fact fact;
|
||
final bool last;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colors = theme.colorScheme;
|
||
final tokens = context.tokens;
|
||
final mornye = context.isMornye;
|
||
// Mornye rows line up with the section header, as in iOS inset lists.
|
||
final inset = mornye ? 16.0 : tokens.rowPaddingH;
|
||
final valueStyle =
|
||
(mornye ? theme.textTheme.bodyLarge : theme.textTheme.bodyMedium)
|
||
?.copyWith(
|
||
color: fact.emphasis ? colors.primary : colors.onSurfaceVariant,
|
||
fontWeight: fact.emphasis ? FontWeight.w600 : null,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
);
|
||
return MergeSemantics(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ConstrainedBox(
|
||
constraints: BoxConstraints(minHeight: tokens.minTouchTarget),
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: inset,
|
||
vertical: tokens.rowPaddingVCompact,
|
||
),
|
||
child: LayoutBuilder(
|
||
// The value keeps its natural width up to just over half the
|
||
// row, so short values never squeeze long labels onto two lines.
|
||
builder: (context, constraints) => Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(fact.label, style: theme.textTheme.bodyLarge),
|
||
),
|
||
SizedBox(width: tokens.gapMd),
|
||
ConstrainedBox(
|
||
constraints: BoxConstraints(
|
||
maxWidth: constraints.maxWidth * 0.55,
|
||
),
|
||
child: Text(
|
||
fact.value,
|
||
textAlign: TextAlign.end,
|
||
style: valueStyle,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (!last && !settingsRowsConnected(context))
|
||
Divider(
|
||
height: 1,
|
||
indent: inset,
|
||
endIndent: mornye ? 0 : inset,
|
||
color: mornye
|
||
? MornyeTheme.metadataDividerColor(context)
|
||
: colors.outlineVariant.withValues(alpha: 0.3),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Section {
|
||
const _Section(this.title, this.facts);
|
||
|
||
final String title;
|
||
final List<_Fact> facts;
|
||
}
|
||
|
||
class _Fact {
|
||
const _Fact(this.label, this.value, {this.emphasis = false});
|
||
|
||
final String label;
|
||
final String value;
|
||
final bool emphasis;
|
||
}
|
||
|
||
const _losslessFormats = {'flac', 'alac', 'wav', 'aiff', 'ape', 'wv', 'pcm'};
|
||
|
||
/// Derives the displayed facts; kept separate from widgets so the copied
|
||
/// report matches the sheet exactly.
|
||
class _AudioFacts {
|
||
_AudioFacts({
|
||
required this.l10n,
|
||
required this.metadata,
|
||
required this.path,
|
||
required this.fileSize,
|
||
required this.route,
|
||
required this.usb,
|
||
required this.equalizer,
|
||
required this.processing,
|
||
required this.settings,
|
||
});
|
||
|
||
final AppLocalizations l10n;
|
||
final Map<String, dynamic> metadata;
|
||
final String? path;
|
||
final int? fileSize;
|
||
final AudioRouteInfo? route;
|
||
final UsbAudioStatus usb;
|
||
final EqualizerSettings equalizer;
|
||
final PlaybackProcessing? processing;
|
||
final AppSettings settings;
|
||
|
||
late final String format = (metadata['format'] ?? '').toString().trim();
|
||
late final String codec = (metadata['audio_codec'] ?? '').toString().trim();
|
||
late final int? sampleRate =
|
||
readPositiveInt(metadata['sample_rate']) ?? route?.stream?.sampleRate;
|
||
late final int? bitDepth =
|
||
readPositiveInt(metadata['bit_depth']) ?? route?.stream?.pcmBits;
|
||
late final int? channels =
|
||
route?.stream?.channels ?? readPositiveInt(metadata['channels']);
|
||
late final int? bitrateKbps =
|
||
readPositiveInt(metadata['bitrate']) ??
|
||
((route?.stream?.bitrate ?? 0) > 0
|
||
? (route!.stream!.bitrate! / 1000).round()
|
||
: null);
|
||
late final int? durationSeconds = readPositiveInt(metadata['duration']);
|
||
late final bool lossless =
|
||
_losslessFormats.contains(format.toLowerCase()) ||
|
||
_losslessFormats.contains(codec.toLowerCase());
|
||
late final bool direct =
|
||
(processing?.direct ?? false) && usb.reason == 'active';
|
||
|
||
List<_Section> sections() => [
|
||
_Section(l10n.audioInfoSource, _source()),
|
||
if (_signal().isNotEmpty) _Section(l10n.audioInfoSignal, _signal()),
|
||
_Section(l10n.audioInfoLoudness, _loudness()),
|
||
_Section(l10n.audioInfoProcessing, _processing()),
|
||
if (_output().isNotEmpty) _Section(l10n.audioInfoOutput, _output()),
|
||
];
|
||
|
||
List<_Fact> _source() {
|
||
final hiRes =
|
||
lossless && ((bitDepth ?? 0) > 16 || (sampleRate ?? 0) > 48000);
|
||
return [
|
||
_Fact(
|
||
l10n.audioInfoQuality,
|
||
hiRes
|
||
? l10n.audioInfoHiResLossless
|
||
: lossless
|
||
? l10n.audioInfoLossless
|
||
: l10n.audioInfoLossy,
|
||
emphasis: true,
|
||
),
|
||
if (format.isNotEmpty) _Fact(l10n.audioInfoFormat, format.toUpperCase()),
|
||
if (codec.isNotEmpty || route?.stream?.mime != null)
|
||
_Fact(
|
||
l10n.audioInfoCodec,
|
||
[
|
||
if (codec.isNotEmpty) codec.toUpperCase(),
|
||
?route?.stream?.mime,
|
||
].join(' · '),
|
||
),
|
||
if (sampleRate != null)
|
||
_Fact(l10n.audioInfoSampleRate, _khz(sampleRate!)),
|
||
if (bitDepth != null) _Fact(l10n.audioInfoBitDepth, '$bitDepth-bit'),
|
||
if (channels != null)
|
||
_Fact(l10n.audioInfoChannels, l10n.audioInfoChannelCount(channels!)),
|
||
if (bitrateKbps != null)
|
||
_Fact(l10n.audioInfoBitrate, '${_thousands(bitrateKbps!)} kbps'),
|
||
if (durationSeconds != null)
|
||
_Fact(l10n.audioInfoDuration, formatClock(durationSeconds!)),
|
||
if (fileSize != null)
|
||
_Fact(l10n.audioInfoFileSize, formatBytes(fileSize!)),
|
||
if (_pcmKbps case final pcm? when bitrateKbps != null)
|
||
_Fact(
|
||
l10n.audioInfoCompression,
|
||
l10n.audioInfoCompressionValue(
|
||
(bitrateKbps! / pcm * 100).toStringAsFixed(lossless ? 0 : 1),
|
||
),
|
||
),
|
||
if (_location case final location?)
|
||
_Fact(l10n.audioInfoLocation, location),
|
||
];
|
||
}
|
||
|
||
double? get _pcmKbps {
|
||
final rate = sampleRate, bits = lossless ? bitDepth : 16, ch = channels;
|
||
if (rate == null || bits == null || ch == null) return null;
|
||
return rate * bits * ch / 1000;
|
||
}
|
||
|
||
String? get _location {
|
||
final path = this.path;
|
||
if (path == null || path.isEmpty) return null;
|
||
if (path.startsWith('network://')) return l10n.audioInfoLocationNetwork;
|
||
if (path.startsWith('content://')) return l10n.audioInfoLocationShared;
|
||
if (path.startsWith('/')) return l10n.audioInfoLocationLocal;
|
||
return null;
|
||
}
|
||
|
||
List<_Fact> _signal() => [
|
||
if (_pcmKbps case final pcm? when lossless)
|
||
_Fact(l10n.audioInfoPcmRate, '${_thousands(pcm.round())} kbps'),
|
||
if (sampleRate != null)
|
||
_Fact(
|
||
l10n.audioInfoFrequencyRange,
|
||
l10n.audioInfoFrequencyRangeValue(_khz(sampleRate! ~/ 2)),
|
||
),
|
||
if (bitDepth case final bits? when lossless)
|
||
_Fact(l10n.audioInfoDynamicRange, '${(6.02 * bits).round()} dB'),
|
||
];
|
||
|
||
List<_Fact> _loudness() {
|
||
final tags = [
|
||
(l10n.audioInfoTrackGain, metadata['replaygain_track_gain'], false),
|
||
(l10n.audioInfoTrackPeak, metadata['replaygain_track_peak'], true),
|
||
(l10n.audioInfoAlbumGain, metadata['replaygain_album_gain'], false),
|
||
(l10n.audioInfoAlbumPeak, metadata['replaygain_album_peak'], true),
|
||
];
|
||
final facts = [
|
||
for (final (label, raw, peak) in tags)
|
||
if (_number(raw) case final value?)
|
||
_Fact(label, peak ? _peak(value) : _db(value)),
|
||
];
|
||
final gain = processing?.normalizationGain ?? 1;
|
||
final String normalization;
|
||
if (!settings.playbackNormalization) {
|
||
normalization = l10n.audioInfoOff;
|
||
} else if (direct || settings.usbBitPerfect) {
|
||
normalization = l10n.audioInfoBypassed;
|
||
} else if (gain > 0 && gain != 1) {
|
||
normalization = l10n.audioInfoApplied(
|
||
_db(20 * math.log(gain) / math.ln10),
|
||
);
|
||
} else {
|
||
normalization = l10n.audioInfoOn;
|
||
}
|
||
return [
|
||
if (facts.isEmpty)
|
||
_Fact(l10n.audioInfoTrackGain, l10n.audioInfoNoGainTags),
|
||
...facts,
|
||
_Fact(l10n.libraryPlaybackNormalization, normalization),
|
||
];
|
||
}
|
||
|
||
List<_Fact> _processing() {
|
||
final route = this.route;
|
||
final String signalPath;
|
||
if (direct && usb.driver == 'usb_direct') {
|
||
signalPath = l10n.audioInfoPathDirect;
|
||
} else if (direct && usb.driver == 'aaudio_exclusive') {
|
||
signalPath = l10n.audioInfoPathExclusive;
|
||
} else {
|
||
signalPath = l10n.audioInfoPathSystem;
|
||
}
|
||
final eq = !equalizer.enabled
|
||
? l10n.audioInfoOff
|
||
: direct
|
||
? l10n.audioInfoBypassed
|
||
: [
|
||
l10n.audioInfoOn,
|
||
l10n.audioInfoBands(equalizer.gains.length),
|
||
if (equalizer.inputGain != 0)
|
||
'${l10n.equalizerPreamp} ${_db(equalizer.inputGain)}',
|
||
if (equalizer.compressor) l10n.equalizerCompressor,
|
||
].join(' · ');
|
||
final rate = processing?.autoMixRate ?? 1;
|
||
final decoder = route?.stream?.decoder;
|
||
return [
|
||
_Fact(l10n.audioInfoPlayer, l10n.audioInfoPlayerValue),
|
||
_Fact(l10n.audioInfoSignalPath, signalPath),
|
||
if (!direct && decoder != null)
|
||
_Fact(
|
||
l10n.audioInfoDecoder,
|
||
[
|
||
decoder,
|
||
if (route?.stream?.hardwareDecoder case final hardware?)
|
||
hardware ? l10n.audioInfoHardware : l10n.audioInfoSoftware,
|
||
].join(' · '),
|
||
),
|
||
_Fact(l10n.equalizerTitle, eq),
|
||
_Fact(
|
||
'AutoMix',
|
||
settings.autoMix && !settings.usbBitPerfect
|
||
? l10n.audioInfoOn
|
||
: l10n.audioInfoOff,
|
||
),
|
||
if ((rate - 1).abs() > 0.001)
|
||
_Fact(l10n.audioInfoTempo, '${rate.toStringAsFixed(3)}×'),
|
||
if (_resampling case final value?) _Fact(l10n.audioInfoResampling, value),
|
||
_Fact(
|
||
l10n.audioInfoBitPerfect,
|
||
_bitPerfect
|
||
? l10n.audioInfoBitPerfectActive
|
||
: l10n.audioInfoBitPerfectNo,
|
||
emphasis: _bitPerfect,
|
||
),
|
||
];
|
||
}
|
||
|
||
// A preferred mixer format is configuration, not proof of active playback.
|
||
bool get _bitPerfect => direct;
|
||
|
||
int? get _outputRate {
|
||
if (direct) return usb.sampleRate;
|
||
return route?.device?.mixerSampleRate ?? route?.mixerSampleRate;
|
||
}
|
||
|
||
String? get _resampling {
|
||
final source = sampleRate, output = _outputRate;
|
||
if (source == null || output == null) return null;
|
||
if (_bitPerfect || source == output) return l10n.audioInfoResamplingNone;
|
||
return '${_khz(source)} → ${_khz(output)}';
|
||
}
|
||
|
||
List<_Fact> _output() {
|
||
final route = this.route;
|
||
final device = route?.device;
|
||
final bufferMs = route?.bufferMs;
|
||
final volume = route?.volume;
|
||
return [
|
||
if (device != null)
|
||
_Fact(
|
||
l10n.audioInfoDevice,
|
||
[
|
||
l10n.audioInfoDeviceType(device.type),
|
||
if (direct && usb.device?.isNotEmpty == true)
|
||
usb.device!
|
||
else if (device.name.isNotEmpty)
|
||
device.name,
|
||
].join(' · '),
|
||
),
|
||
if (_outputRate case final rate?)
|
||
_Fact(l10n.audioInfoOutputRate, _khz(rate)),
|
||
if (direct && usb.bitDepth != null)
|
||
_Fact(
|
||
l10n.audioInfoOutputFormat,
|
||
[
|
||
'${usb.bitDepth}-bit',
|
||
if (usb.transport != 'pcm') usb.transport.toUpperCase(),
|
||
].join(' · '),
|
||
)
|
||
else if (device?.mixerBitDepth case final bits?)
|
||
_Fact(l10n.audioInfoOutputFormat, '$bits-bit'),
|
||
if (device != null && route?.platform == 'android')
|
||
_Fact(
|
||
l10n.audioInfoDeviceRates,
|
||
device.sampleRates.isEmpty
|
||
? l10n.audioInfoAnyRate
|
||
: device.sampleRates.map(_khz).join(', '),
|
||
),
|
||
if (device != null && (device.bitDepths.isNotEmpty || device.floatOutput))
|
||
_Fact(
|
||
l10n.audioInfoDeviceFormats,
|
||
[
|
||
for (final bits in device.bitDepths)
|
||
if (bits != 32 || !device.floatOutput) '$bits-bit',
|
||
if (device.floatOutput) '32-bit float',
|
||
].join(', '),
|
||
),
|
||
if (route?.outputChannels case final count?)
|
||
_Fact(l10n.audioInfoChannels, l10n.audioInfoChannelCount(count)),
|
||
if (bufferMs != null)
|
||
_Fact(
|
||
l10n.audioInfoBuffer,
|
||
[
|
||
'${bufferMs.toStringAsFixed(1)} ms',
|
||
if (route?.framesPerBuffer case final frames?) '$frames frames',
|
||
].join(' · '),
|
||
),
|
||
if (route?.outputLatencyMs case final latency?)
|
||
_Fact(l10n.audioInfoLatency, '${latency.toStringAsFixed(1)} ms'),
|
||
if (route?.lowLatency case final supported?)
|
||
_Fact(l10n.audioInfoLowLatency, _supported(supported)),
|
||
if (route?.proAudio case final supported?)
|
||
_Fact(l10n.audioInfoProAudio, _supported(supported)),
|
||
if (route?.spatialAvailable != null || route?.spatialEnabled != null)
|
||
_Fact(
|
||
l10n.audioInfoSpatial,
|
||
route?.spatialAvailable == false
|
||
? l10n.audioInfoUnavailable
|
||
: route?.spatialEnabled == true
|
||
? l10n.audioInfoOn
|
||
: l10n.audioInfoOff,
|
||
),
|
||
if (route?.headTracking == true)
|
||
_Fact(l10n.audioInfoHeadTracking, l10n.audioInfoSupported),
|
||
if (route?.fixedVolume == true || direct)
|
||
_Fact(l10n.nowPlayingVolume, l10n.audioInfoFixedVolume)
|
||
else if (volume != null)
|
||
_Fact(
|
||
l10n.nowPlayingVolume,
|
||
route?.maxVolume != null
|
||
? '${volume.round()} / ${route!.maxVolume}'
|
||
: '${(volume * 100).round()}%',
|
||
),
|
||
];
|
||
}
|
||
|
||
String _supported(bool value) =>
|
||
value ? l10n.audioInfoSupported : l10n.audioInfoNotSupported;
|
||
}
|
||
|
||
String _khz(int hz) {
|
||
final khz = hz / 1000;
|
||
final text = khz == khz.roundToDouble()
|
||
? khz.toStringAsFixed(0)
|
||
: khz.toStringAsFixed(khz * 10 == (khz * 10).roundToDouble() ? 1 : 2);
|
||
return '$text kHz';
|
||
}
|
||
|
||
String _thousands(int value) {
|
||
final digits = value.toString();
|
||
final buffer = StringBuffer();
|
||
for (var i = 0; i < digits.length; i++) {
|
||
if (i > 0 && (digits.length - i) % 3 == 0) buffer.write(',');
|
||
buffer.write(digits[i]);
|
||
}
|
||
return buffer.toString();
|
||
}
|
||
|
||
/// Signed decibels with a true minus sign.
|
||
String _db(double value) {
|
||
final text = value.abs().toStringAsFixed(2);
|
||
if (value.abs() < 0.005) return '0.00 dB';
|
||
return '${value < 0 ? '−' : '+'}$text dB';
|
||
}
|
||
|
||
/// A linear sample peak with its level in dBFS.
|
||
String _peak(double value) {
|
||
if (value <= 0) return value.toStringAsFixed(6);
|
||
final dbfs = 20 * math.log(value) / math.ln10;
|
||
return '${value.toStringAsFixed(6)} · ${_db(dbfs).replaceFirst(' dB', ' dBFS')}';
|
||
}
|
||
|
||
final _numberPattern = RegExp(r'[-+]?\d+(?:\.\d+)?');
|
||
|
||
/// Reads tag values such as "-6.52 dB" or "0.988831".
|
||
double? _number(Object? raw) {
|
||
if (raw is num) return raw.toDouble();
|
||
final match = _numberPattern.firstMatch(raw?.toString() ?? '');
|
||
return match == null ? null : double.tryParse(match[0]!);
|
||
}
|