feat(player): show audio source processing and output details

This commit is contained in:
zarzet committed 2026-10-17 16:18:32 +07:00
1 parent ecf594aac2
commit 40f65d3a4d
10 files changed
+1352 -2

No files matched your search

@@ -0,0 +1,157 @@
package com.zarz.spotiflac
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioMixerAttributes
import android.media.MediaCodecList
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import android.os.Build
/**
* Read-only route and stream facts for the player's audio information sheet.
* Nothing here changes routing, volume or the playing track.
*/
internal object AudioRouteInfo {
private val media = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()
fun read(context: Context, path: String?): Map<String, Any?> {
val manager = context.getSystemService(AudioManager::class.java)
val features = context.packageManager
val device = activeDevice(manager)
return mapOf(
"platform" to "android",
"sdk" to Build.VERSION.SDK_INT,
"mixerSampleRate" to manager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull(),
"framesPerBuffer" to manager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)?.toIntOrNull(),
"lowLatency" to features.hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY),
"proAudio" to features.hasSystemFeature(PackageManager.FEATURE_AUDIO_PRO),
"volume" to manager.getStreamVolume(AudioManager.STREAM_MUSIC),
"maxVolume" to manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
"fixedVolume" to manager.isVolumeFixed,
"device" to device?.let { describe(manager, it) },
"spatializer" to spatializer(manager),
"stream" to path?.let { runCatching { stream(context, it) }.getOrNull() },
)
}
/** The device media is routed to; older releases fall back to priority. */
private fun activeDevice(manager: AudioManager): AudioDeviceInfo? {
if (Build.VERSION.SDK_INT >= 33) {
manager.getAudioDevicesForAttributes(media).firstOrNull()?.let { routed ->
return manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.firstOrNull { it.type == routed.type && it.address == routed.address }
?: manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { it.type == routed.type }
}
}
val outputs = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val priority = listOf(
AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
)
return priority.firstNotNullOfOrNull { type -> outputs.firstOrNull { it.type == type } }
}
private fun describe(manager: AudioManager, device: AudioDeviceInfo): Map<String, Any?> {
val values = mutableMapOf<String, Any?>(
"type" to typeName(device.type),
"name" to device.productName?.toString()?.trim().orEmpty(),
"sampleRates" to device.sampleRates.toList().distinct().sorted(),
"bitDepths" to device.encodings.toList().mapNotNull { bits(it) }.distinct().sorted(),
"float" to device.encodings.contains(AudioFormat.ENCODING_PCM_FLOAT),
"channelCounts" to device.channelCounts.toList().distinct().sorted(),
)
if (Build.VERSION.SDK_INT >= 34) {
val supported = runCatching { manager.getSupportedMixerAttributes(device) }.getOrDefault(emptyList())
values["bitPerfectCapable"] = supported.any {
it.mixerBehavior == AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT
}
runCatching { manager.getPreferredMixerAttributes(media, device) }.getOrNull()?.let {
values["mixer"] = mapOf(
"sampleRate" to it.format.sampleRate,
"bitDepth" to bits(it.format.encoding),
"bitPerfect" to (it.mixerBehavior == AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT),
)
}
}
return values
}
private fun spatializer(manager: AudioManager): Map<String, Any?>? {
if (Build.VERSION.SDK_INT < 32) return null
val spatializer = manager.spatializer
return mapOf(
"available" to spatializer.isAvailable,
"enabled" to spatializer.isEnabled,
"headTracking" to (Build.VERSION.SDK_INT >= 33 && spatializer.isHeadTrackerAvailable),
)
}
/** What the platform extractor and decoder see for the playing file. */
private fun stream(context: Context, path: String): Map<String, Any?>? {
if (!path.startsWith("/") && !path.startsWith("content://")) return null
val extractor = MediaExtractor()
try {
if (path.startsWith("content://")) {
extractor.setDataSource(context, Uri.parse(path), null)
} else {
extractor.setDataSource(path)
}
val format = (0 until extractor.trackCount).map(extractor::getTrackFormat)
.firstOrNull { it.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true }
?: return null
fun int(key: String) = if (format.containsKey(key)) runCatching { format.getInteger(key) }.getOrNull() else null
val decoder = runCatching {
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format)
}.getOrNull()
val info = decoder?.let { name ->
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.firstOrNull { it.name == name }
}
return mapOf(
"mime" to format.getString(MediaFormat.KEY_MIME),
"sampleRate" to int(MediaFormat.KEY_SAMPLE_RATE),
"channels" to int(MediaFormat.KEY_CHANNEL_COUNT),
"bitrate" to int(MediaFormat.KEY_BIT_RATE),
"pcmBits" to int(MediaFormat.KEY_PCM_ENCODING)?.let(::bits),
"durationUs" to if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else null,
"decoder" to decoder,
"hardwareDecoder" to if (Build.VERSION.SDK_INT >= 29) info?.isHardwareAccelerated else null,
)
} finally {
extractor.release()
}
}
private fun bits(encoding: Int): Int? = when (encoding) {
AudioFormat.ENCODING_PCM_8BIT -> 8
AudioFormat.ENCODING_PCM_16BIT -> 16
AudioFormat.ENCODING_PCM_24BIT_PACKED -> 24
AudioFormat.ENCODING_PCM_32BIT, AudioFormat.ENCODING_PCM_FLOAT -> 32
else -> null
}
private fun typeName(type: Int): String = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> "speaker"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "earpiece"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "wired_headphones"
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wired_headset"
AudioDeviceInfo.TYPE_LINE_ANALOG, AudioDeviceInfo.TYPE_AUX_LINE -> "line"
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bluetooth"
AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_BLE_SPEAKER,
AudioDeviceInfo.TYPE_BLE_BROADCAST -> "bluetooth_le"
AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_ACCESSORY -> "usb"
AudioDeviceInfo.TYPE_USB_HEADSET -> "usb_headset"
AudioDeviceInfo.TYPE_HDMI, AudioDeviceInfo.TYPE_HDMI_ARC, AudioDeviceInfo.TYPE_HDMI_EARC -> "hdmi"
AudioDeviceInfo.TYPE_DOCK -> "dock"
AudioDeviceInfo.TYPE_REMOTE_SUBMIX -> "cast"
AudioDeviceInfo.TYPE_HEARING_AID -> "hearing_aid"
else -> "other"
}
}
@@ -24,6 +24,7 @@ import java.nio.ByteBuffer
class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private lateinit var methods: MethodChannel
private lateinit var events: EventChannel
private lateinit var route: MethodChannel
private var engine: UsbPcmPlayback? = null
private var direct: UsbDirectPlayback? = null
private var useDirect = false
@@ -39,6 +40,19 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
methods.setMethodCallHandler(this)
events.setStreamHandler(this)
appContext = binding.applicationContext
route = MethodChannel(binding.binaryMessenger, "com.zarz.spotiflac/audio_route")
route.setMethodCallHandler { call, result ->
if (call.method != "read") return@setMethodCallHandler result.notImplemented()
val path = call.argument<String>("path")
// Opening an extractor touches storage; keep it off the main thread.
Thread {
val info = runCatching { AudioRouteInfo.read(appContext, path) }
main.post {
info.onSuccess(result::success)
.onFailure { result.error("audio_route", it.message, null) }
}
}.start()
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@@ -97,6 +111,7 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methods.setMethodCallHandler(null)
events.setStreamHandler(null)
route.setMethodCallHandler(null)
sink = null
direct?.dispose()
direct = null
+2
View File
@@ -25,6 +25,7 @@ import UniformTypeIdentifiers
private var backendChannel: FlutterMethodChannel?
private var concertCalendar: ConcertCalendarBridge?
private var playbackNotification: PlaybackNotificationBridge?
private var audioRouteInfo: AudioRouteInfoBridge?
private let coreBackend: CoreBackend = createCoreBackend()
private var pendingSessionGrantEvents: [[String: Any]] = []
@@ -115,6 +116,7 @@ import UniformTypeIdentifiers
GeneratedPluginRegistrant.register(with: registry)
playbackNotification = PlaybackNotificationBridge(messenger: messenger)
audioRouteInfo = AudioRouteInfoBridge(messenger: messenger)
registry.registrar(forPlugin: "AudioOutputView")?.register(
AudioOutputViewFactory(messenger: messenger),
withId: "com.zarz.spotiflac/audio_output"
+88
View File
@@ -36,6 +36,94 @@ final class PlaybackNotificationBridge {
}
}
/// Read-only route and stream facts for the player's audio information sheet.
final class AudioRouteInfoBridge {
private let channel: FlutterMethodChannel
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "com.zarz.spotiflac/audio_route", binaryMessenger: messenger)
channel.setMethodCallHandler { call, result in
guard call.method == "read" else {
result(FlutterMethodNotImplemented)
return
}
let path = (call.arguments as? [String: Any])?["path"] as? String
Task {
var info = Self.route()
if let path { info["stream"] = await Self.stream(path) }
let payload = info
await MainActor.run { result(payload) }
}
}
}
private static func route() -> [String: Any] {
let session = AVAudioSession.sharedInstance()
var info: [String: Any] = [
"platform": "ios",
"mixerSampleRate": Int(session.sampleRate.rounded()),
"ioBufferMs": session.ioBufferDuration * 1000,
"outputLatencyMs": session.outputLatency * 1000,
"outputChannels": session.outputNumberOfChannels,
"maxOutputChannels": session.maximumOutputNumberOfChannels,
"volume": Double(session.outputVolume),
]
if let output = session.currentRoute.outputs.first {
info["device"] = [
"type": portType(output.portType),
"name": output.portName,
"channelCounts": [output.channels?.count ?? 0].filter { $0 > 0 },
"spatial": output.isSpatialAudioEnabled,
] as [String: Any]
}
return info
}
private static func portType(_ type: AVAudioSession.Port) -> String {
switch type {
case .builtInSpeaker: return "speaker"
case .builtInReceiver: return "earpiece"
case .headphones: return "wired_headphones"
case .bluetoothA2DP, .bluetoothHFP: return "bluetooth"
case .bluetoothLE: return "bluetooth_le"
case .airPlay: return "airplay"
case .usbAudio: return "usb"
case .HDMI: return "hdmi"
case .lineOut: return "line"
case .carAudio: return "car"
default: return "other"
}
}
private static func stream(_ path: String) async -> [String: Any]? {
let url = path.hasPrefix("/") ? URL(fileURLWithPath: path) : URL(string: path)
guard let url, url.isFileURL else { return nil }
let asset = AVURLAsset(url: url)
guard let track = try? await asset.loadTracks(withMediaType: .audio).first,
let formats = try? await track.load(.formatDescriptions),
let description = formats.first,
let basic = CMAudioFormatDescriptionGetStreamBasicDescription(description)?.pointee
else { return nil }
let rate = (try? await track.load(.estimatedDataRate)) ?? 0
let id = basic.mFormatID
let fourCC = String(
bytes: [24, 16, 8, 0].map { UInt8((id >> $0) & 0xff) },
encoding: .ascii
)?.trimmingCharacters(in: .whitespaces) ?? ""
return [
"mime": fourCC,
"sampleRate": Int(basic.mSampleRate.rounded()),
"channels": Int(basic.mChannelsPerFrame),
"pcmBits": Int(basic.mBitsPerChannel),
"bitrate": Int(rate.rounded()),
].filter { ($0.value as? Int).map { $0 > 0 } ?? !(($0.value as? String)?.isEmpty ?? true) }
}
deinit {
channel.setMethodCallHandler(nil)
}
}
final class AudioOutputViewFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
@@ -30,6 +30,10 @@ class PlayerMetadataController extends ChangeNotifier {
bool _disposed = false;
String? get source => _requestKey?.$2;
/// The path the current metadata was probed from: the resolved copy of a
/// document, or the source itself.
String? get filePath => _loadedPath;
Map<String, dynamic>? _metadata;
ParsedLyrics _lyrics = ParsedLyrics.empty;
bool _loading = false;
+197
View File
@@ -0,0 +1,197 @@
import 'package:flutter/services.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('AudioRoute');
/// Output route and platform stream facts for the audio information sheet.
/// Every field is optional: platforms and releases expose different subsets.
class AudioRouteInfo {
const AudioRouteInfo({
this.platform,
this.sdk,
this.mixerSampleRate,
this.framesPerBuffer,
this.ioBufferMs,
this.outputLatencyMs,
this.outputChannels,
this.lowLatency,
this.proAudio,
this.volume,
this.maxVolume,
this.fixedVolume,
this.device,
this.spatialAvailable,
this.spatialEnabled,
this.headTracking,
this.stream,
});
factory AudioRouteInfo.fromMap(Map<dynamic, dynamic> map) {
final device = map['device'];
final spatial = map['spatializer'];
final stream = map['stream'];
return AudioRouteInfo(
platform: map['platform'] as String?,
sdk: _int(map['sdk']),
mixerSampleRate: _int(map['mixerSampleRate']),
framesPerBuffer: _int(map['framesPerBuffer']),
ioBufferMs: _double(map['ioBufferMs']),
outputLatencyMs: _double(map['outputLatencyMs']),
outputChannels: _int(map['outputChannels']),
lowLatency: map['lowLatency'] as bool?,
proAudio: map['proAudio'] as bool?,
volume: (map['volume'] as num?)?.toDouble(),
maxVolume: _int(map['maxVolume']),
fixedVolume: map['fixedVolume'] as bool?,
device: device is Map ? AudioRouteDevice.fromMap(device) : null,
spatialAvailable: spatial is Map ? spatial['available'] as bool? : null,
spatialEnabled: spatial is Map
? spatial['enabled'] as bool?
: device is Map
? device['spatial'] as bool?
: null,
headTracking: spatial is Map ? spatial['headTracking'] as bool? : null,
stream: stream is Map ? AudioRouteStream.fromMap(stream) : null,
);
}
final String? platform;
final int? sdk;
/// The system mixer's native rate; sources at other rates are resampled
/// unless a bit-perfect path bypasses the mixer.
final int? mixerSampleRate;
final int? framesPerBuffer;
final double? ioBufferMs;
final double? outputLatencyMs;
final int? outputChannels;
final bool? lowLatency;
final bool? proAudio;
/// Android: stream steps out of [maxVolume]. iOS: a 0–1 fraction.
final double? volume;
final int? maxVolume;
final bool? fixedVolume;
final AudioRouteDevice? device;
final bool? spatialAvailable;
final bool? spatialEnabled;
final bool? headTracking;
final AudioRouteStream? stream;
/// Mixer buffer duration in milliseconds, from frames on Android.
double? get bufferMs {
if (ioBufferMs != null) return ioBufferMs;
final frames = framesPerBuffer, rate = mixerSampleRate;
if (frames == null || rate == null || rate <= 0) return null;
return frames * 1000 / rate;
}
}
class AudioRouteDevice {
const AudioRouteDevice({
required this.type,
this.name = '',
this.sampleRates = const [],
this.bitDepths = const [],
this.floatOutput = false,
this.channelCounts = const [],
this.bitPerfectCapable,
this.mixerSampleRate,
this.mixerBitDepth,
this.mixerBitPerfect,
});
factory AudioRouteDevice.fromMap(Map<dynamic, dynamic> map) {
final mixer = map['mixer'];
return AudioRouteDevice(
type: map['type'] as String? ?? 'other',
name: (map['name'] as String? ?? '').trim(),
sampleRates: _ints(map['sampleRates']),
bitDepths: _ints(map['bitDepths']),
floatOutput: map['float'] == true,
channelCounts: _ints(map['channelCounts']),
bitPerfectCapable: map['bitPerfectCapable'] as bool?,
mixerSampleRate: mixer is Map ? _int(mixer['sampleRate']) : null,
mixerBitDepth: mixer is Map ? _int(mixer['bitDepth']) : null,
mixerBitPerfect: mixer is Map ? mixer['bitPerfect'] as bool? : null,
);
}
/// speaker, wired_headphones, bluetooth, usb, airplay, … (see natives).
final String type;
final String name;
/// Empty when the device accepts any rate the mixer offers.
final List<int> sampleRates;
final List<int> bitDepths;
final bool floatOutput;
final List<int> channelCounts;
final bool? bitPerfectCapable;
final int? mixerSampleRate;
final int? mixerBitDepth;
final bool? mixerBitPerfect;
}
class AudioRouteStream {
const AudioRouteStream({
this.mime,
this.sampleRate,
this.channels,
this.bitrate,
this.pcmBits,
this.decoder,
this.hardwareDecoder,
});
factory AudioRouteStream.fromMap(Map<dynamic, dynamic> map) =>
AudioRouteStream(
mime: map['mime'] as String?,
sampleRate: _int(map['sampleRate']),
channels: _int(map['channels']),
bitrate: _int(map['bitrate']),
pcmBits: _int(map['pcmBits']),
decoder: map['decoder'] as String?,
hardwareDecoder: map['hardwareDecoder'] as bool?,
);
/// MIME type on Android (audio/flac), a format code on iOS (flac, alac).
final String? mime;
final int? sampleRate;
final int? channels;
/// Bits per second.
final int? bitrate;
final int? pcmBits;
final String? decoder;
final bool? hardwareDecoder;
}
int? _int(Object? value) => value is num && value > 0 ? value.toInt() : null;
double? _double(Object? value) =>
value is num && value > 0 ? value.toDouble() : null;
List<int> _ints(Object? value) => value is List
? [
for (final item in value)
if (item is num && item > 0) item.toInt(),
]
: const [];
const _channel = MethodChannel('com.zarz.spotiflac/audio_route');
/// Reads the current route; [path] adds what the platform decoder sees for a
/// local file. Returns null where the platform has no implementation.
Future<AudioRouteInfo?> readAudioRouteInfo({String? path}) async {
try {
final map = await _channel.invokeMapMethod<String, dynamic>('read', {
'path': ?path,
});
return map == null ? null : AudioRouteInfo.fromMap(map);
} on MissingPluginException {
return null;
} catch (error) {
_log.w('Failed to read audio route: $error');
return null;
}
}
+26
View File
@@ -115,6 +115,24 @@ void setUsbOutputOptions({
),
);
/// Gain and rate the player applies to the current track.
class PlaybackProcessing {
const PlaybackProcessing({
required this.normalizationGain,
required this.direct,
required this.autoMixRate,
});
/// Linear volume from ReplayGain normalization; 1 when none applies.
final double normalizationGain;
/// A direct USB or exclusive path that bypasses the system mixer and DSP.
final bool direct;
/// AutoMix tempo matching; 1 outside a transition.
final double autoMixRate;
}
/// Refreshes gain tags after a successful file update, including SAF copies.
void refreshPlaybackNormalization(String source) {
final handler = musicPlayerRuntime.handler;
@@ -1013,6 +1031,14 @@ class MusicPlayerHandler extends BaseAudioHandler
);
}
/// What the active output path applies to the playing track, for the
/// audio information sheet.
PlaybackProcessing get processing => PlaybackProcessing(
normalizationGain: _normalizationVolume,
direct: _player.isDirect,
autoMixRate: _autoMix.rate,
);
/// Re-applies normalization to the playing track when the setting flips.
void reapplyNormalization() {
final index = _index;
+679
View File
@@ -0,0 +1,679 @@
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')}';
}
/// Reads tag values such as "-6.52 dB" or "0.988831".
double? _number(Object? raw) {
if (raw is num) return raw.toDouble();
final match = RegExp(r'[-+]?\d+(?:\.\d+)?').firstMatch(raw?.toString() ?? '');
return match == null ? null : double.tryParse(match[0]!);
}
+21 -2
View File
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/audio_info_sheet.dart';
import 'package:spotiflac_android/widgets/automix_status_badge.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/widgets/expressive_playback_transport.dart';
@@ -362,7 +363,8 @@ class _QualityBadge extends StatelessWidget {
final text = metadata.qualityLabel;
if (text == null || text.isEmpty) return const SizedBox.shrink();
final mornye = context.isMornye;
return Container(
final radius = BorderRadius.circular(20);
final badge = Container(
padding: EdgeInsets.symmetric(
horizontal: mornye ? 8 : 10,
vertical: mornye ? 2 : 5,
@@ -371,7 +373,7 @@ class _QualityBadge extends StatelessWidget {
? null
: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(20),
borderRadius: radius,
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -397,5 +399,22 @@ class _QualityBadge extends StatelessWidget {
],
),
);
final label = context.l10n.audioInfoTitle;
return Semantics(
button: true,
label: label,
child: Tooltip(
message: label,
excludeFromSemantics: true,
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: radius,
onTap: () => showAudioInfoSheet(context, metadata: metadata),
child: badge,
),
),
),
);
}
}
+163
View File
@@ -0,0 +1,163 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/services/audio_route_info.dart';
import 'package:spotiflac_android/widgets/audio_info_sheet.dart';
const _route = MethodChannel('com.zarz.spotiflac/audio_route');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_route, null);
});
test('route info tolerates partial platform maps', () {
final info = AudioRouteInfo.fromMap({
'mixerSampleRate': 48000,
'framesPerBuffer': 192,
'device': {
'type': 'usb',
'sampleRates': [48000, 44100, 0],
'mixer': {'sampleRate': 96000, 'bitPerfect': true},
},
'spatializer': {'available': false},
});
expect(info.bufferMs, 4);
expect(info.device?.sampleRates, [48000, 44100]);
expect(info.device?.mixerSampleRate, 96000);
expect(info.device?.mixerBitPerfect, isTrue);
expect(info.spatialAvailable, isFalse);
expect(info.stream, isNull);
});
testWidgets('shows source, signal and resampling to the output device', (
tester,
) async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
_route,
(call) async => {
'platform': 'android',
'mixerSampleRate': 48000,
'framesPerBuffer': 96,
'device': {
'type': 'usb',
'name': 'Example DAC',
// A leftover preference must not claim bit-perfect playback.
'mixer': {'sampleRate': 48000, 'bitPerfect': true},
},
},
);
final metadata = PlayerMetadataController();
addTearDown(metadata.dispose);
await metadata.load(
const MediaItem(
id: 'track',
title: 'Track',
extras: {
'source': 'https://example.com/track.flac',
'format': 'flac',
'bit_depth': 24,
'sample_rate': 96000,
},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => TextButton(
onPressed: () => showAudioInfoSheet(context, metadata: metadata),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Audio info'), findsOneWidget);
expect(find.text('Hi-Res Lossless'), findsOneWidget);
expect(find.text('96 kHz'), findsOneWidget);
expect(find.text('Up to 48 kHz (Nyquist)'), findsOneWidget);
expect(find.text('144 dB'), findsOneWidget);
expect(find.text('No ReplayGain tags'), findsOneWidget);
await tester.scrollUntilVisible(find.text('96 kHz → 48 kHz'), 200);
expect(find.text('96 kHz → 48 kHz'), findsOneWidget);
expect(find.text('Yes, unaltered samples'), findsNothing);
await tester.scrollUntilVisible(find.text('USB audio · Example DAC'), 200);
await tester.scrollUntilVisible(find.text('2.0 ms · 96 frames'), 200);
expect(find.text('2.0 ms · 96 frames'), findsOneWidget);
});
testWidgets('late route replies cannot replace the latest track snapshot', (
tester,
) async {
final replies = <Completer<Map<String, dynamic>>>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_route, (call) {
final reply = Completer<Map<String, dynamic>>();
replies.add(reply);
return reply.future;
});
final metadata = PlayerMetadataController();
addTearDown(metadata.dispose);
Future<void> load(String id) => metadata.load(
MediaItem(
id: id,
title: id,
extras: {
'source': 'https://example.test/$id.flac',
'format': 'flac',
'bit_depth': 24,
'sample_rate': 96000,
},
),
);
await load('a');
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => TextButton(
onPressed: () => showAudioInfoSheet(context, metadata: metadata),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await load('b');
await tester.pump();
await load('a');
await tester.pump();
expect(replies, hasLength(3));
replies.last.complete({
'mixerSampleRate': 96000,
'device': {'type': 'usb', 'name': 'Current DAC'},
});
await tester.pumpAndSettle();
for (final reply in replies.take(2)) {
reply.complete({
'mixerSampleRate': 48000,
'device': {'type': 'usb', 'name': 'Stale DAC'},
});
}
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('USB audio · Current DAC'), 200);
expect(find.text('USB audio · Stale DAC'), findsNothing);
});
}