mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
feat(player): show audio source processing and output details
This commit is contained in:
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]!);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user