diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/AudioRouteInfo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/AudioRouteInfo.kt new file mode 100644 index 00000000..ff0e8f41 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/AudioRouteInfo.kt @@ -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 { + 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 { + val values = mutableMapOf( + "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? { + 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? { + 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" + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt index ddc40895..3b99c696 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt @@ -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("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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8ec2ba35..88c2c818 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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" diff --git a/ios/Runner/AudioOutputView.swift b/ios/Runner/AudioOutputView.swift index 17fa6cca..4535fa20 100644 --- a/ios/Runner/AudioOutputView.swift +++ b/ios/Runner/AudioOutputView.swift @@ -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 diff --git a/lib/controllers/player_metadata_controller.dart b/lib/controllers/player_metadata_controller.dart index 5fd33eae..5bcfad13 100644 --- a/lib/controllers/player_metadata_controller.dart +++ b/lib/controllers/player_metadata_controller.dart @@ -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? _metadata; ParsedLyrics _lyrics = ParsedLyrics.empty; bool _loading = false; diff --git a/lib/services/audio_route_info.dart b/lib/services/audio_route_info.dart new file mode 100644 index 00000000..d8db7b43 --- /dev/null +++ b/lib/services/audio_route_info.dart @@ -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 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 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 sampleRates; + final List bitDepths; + final bool floatOutput; + final List 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 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 _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 readAudioRouteInfo({String? path}) async { + try { + final map = await _channel.invokeMapMethod('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; + } +} diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 130eda9c..cc41f39d 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -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; diff --git a/lib/widgets/audio_info_sheet.dart b/lib/widgets/audio_info_sheet.dart new file mode 100644 index 00000000..867cc623 --- /dev/null +++ b/lib/widgets/audio_info_sheet.dart @@ -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 showAudioInfoSheet( + BuildContext context, { + required PlayerMetadataController metadata, +}) => showAppScrollableBottomSheet( + 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 _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([ + 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 _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 _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 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 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]!); +} diff --git a/lib/widgets/player_playback_controls.dart b/lib/widgets/player_playback_controls.dart index c5d32b3d..83a0e3e0 100644 --- a/lib/widgets/player_playback_controls.dart +++ b/lib/widgets/player_playback_controls.dart @@ -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, + ), + ), + ), + ); } } diff --git a/test/audio_info_sheet_test.dart b/test/audio_info_sheet_test.dart new file mode 100644 index 00000000..cb84d2ce --- /dev/null +++ b/test/audio_info_sheet_test.dart @@ -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 = >>[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_route, (call) { + final reply = Completer>(); + replies.add(reply); + return reply.future; + }); + final metadata = PlayerMetadataController(); + addTearDown(metadata.dispose); + Future 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); + }); +}