mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(player): add verified USB bit-perfect PCM output
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UsbBitPerfectPluginTest {
|
||||
@Test
|
||||
fun unsupportedRouteReturnsFallbackAndEngineDetachesCleanly() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val manager = context.getSystemService(AudioManager::class.java)
|
||||
assumeTrue(manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).none {
|
||||
it.type == AudioDeviceInfo.TYPE_USB_DEVICE || it.type == AudioDeviceInfo.TYPE_USB_HEADSET
|
||||
})
|
||||
lateinit var engine: FlutterEngine
|
||||
lateinit var plugin: UsbBitPerfectPlugin
|
||||
instrumentation.runOnMainSync {
|
||||
engine = FlutterEngine(context)
|
||||
plugin = UsbBitPerfectPlugin()
|
||||
engine.plugins.add(plugin)
|
||||
}
|
||||
try {
|
||||
fun invoke(method: String, arguments: Map<String, Any>? = null): Any? {
|
||||
val done = CountDownLatch(1)
|
||||
var value: Any? = null
|
||||
var failure: String? = null
|
||||
instrumentation.runOnMainSync {
|
||||
plugin.onMethodCall(MethodCall(method, arguments), object : MethodChannel.Result {
|
||||
override fun success(result: Any?) { value = result; done.countDown() }
|
||||
override fun error(code: String, message: String?, details: Any?) {
|
||||
failure = "$code: $message"
|
||||
done.countDown()
|
||||
}
|
||||
override fun notImplemented() { failure = "not implemented"; done.countDown() }
|
||||
})
|
||||
}
|
||||
assertTrue("Native method timed out", done.await(10, TimeUnit.SECONDS))
|
||||
assertNull(failure)
|
||||
return value
|
||||
}
|
||||
val result = invoke("prepare", mapOf("path" to "/unopened.flac", "token" to 1)) as Map<*, *>
|
||||
assertEquals(if (android.os.Build.VERSION.SDK_INT >= 34) "no_usb" else "android_version", result["reason"])
|
||||
assertNull(result["ready"])
|
||||
invoke("stop")
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { engine.destroy() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC Mobile"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/** Lossless PCM representation changes only: no gain, dithering or resampling. */
|
||||
internal object BitPerfectPcm {
|
||||
fun outputBits(sourceBits: Int, supported: List<Int>): Int? =
|
||||
supported.filter { it in listOf(16, 24, 32) && it >= sourceBits }.minOrNull()
|
||||
|
||||
fun convert(input: ByteBuffer, inputBits: Int, sourceBits: Int, outputBits: Int, floating: Boolean): ByteBuffer {
|
||||
require(sourceBits in listOf(16, 24, 32) && outputBits in listOf(16, 24, 32) && outputBits >= sourceBits)
|
||||
require(if (floating) inputBits == 32 && sourceBits <= 24 else inputBits >= sourceBits)
|
||||
val bytes = inputBits / 8
|
||||
require(input.remaining() % bytes == 0)
|
||||
input.order(ByteOrder.LITTLE_ENDIAN)
|
||||
val output = ByteBuffer.allocateDirect(input.remaining() / bytes * (outputBits / 8))
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (input.hasRemaining()) {
|
||||
val sample = if (floating) {
|
||||
val scaled = input.float.toDouble() * (1L shl (sourceBits - 1))
|
||||
require(scaled.isFinite() && scaled >= -(1L shl (sourceBits - 1)) &&
|
||||
scaled < (1L shl (sourceBits - 1)) && scaled == scaled.toLong().toDouble())
|
||||
scaled.toInt() shl (32 - sourceBits)
|
||||
} else {
|
||||
when (inputBits) {
|
||||
16 -> input.short.toInt() shl 16
|
||||
24 -> ((input.get().toInt() and 255) or
|
||||
((input.get().toInt() and 255) shl 8) or
|
||||
((input.get().toInt() and 255) shl 16)) shl 8
|
||||
32 -> input.int
|
||||
else -> error("Unsupported PCM encoding")
|
||||
}
|
||||
}
|
||||
// Reject a decoder output containing more precision than the source contract.
|
||||
require(sourceBits == 32 || (sample shl sourceBits) == 0)
|
||||
when (outputBits) {
|
||||
16 -> output.putShort((sample shr 16).toShort())
|
||||
24 -> {
|
||||
output.put((sample shr 8).toByte())
|
||||
output.put((sample shr 16).toByte())
|
||||
output.put((sample shr 24).toByte())
|
||||
}
|
||||
32 -> output.putInt(sample)
|
||||
}
|
||||
}
|
||||
output.flip()
|
||||
return output
|
||||
}
|
||||
|
||||
fun flacFormat(header: ByteArray): Triple<Int, Int, Int>? {
|
||||
if (header.size < 42 || header.take(4) != listOf<Byte>(102, 76, 97, 67) ||
|
||||
header[4].toInt() and 127 != 0 || header[5].toInt() != 0 ||
|
||||
header[6].toInt() != 0 || header[7].toInt() != 34) return null
|
||||
fun byte(i: Int) = header[i].toInt() and 255
|
||||
val rate = (byte(18) shl 12) or (byte(19) shl 4) or (byte(20) shr 4)
|
||||
val channels = ((byte(20) shr 1) and 7) + 1
|
||||
val bits = (((byte(20) and 1) shl 4) or (byte(21) shr 4)) + 1
|
||||
return Triple(rate, channels, bits)
|
||||
}
|
||||
}
|
||||
@@ -976,6 +976,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
onBackPressedDispatcher.addCallback(this, flutterBackCallback!!)
|
||||
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
if (!flutterEngine.plugins.has(UsbBitPerfectPlugin::class.java)) {
|
||||
flutterEngine.plugins.add(UsbBitPerfectPlugin())
|
||||
}
|
||||
concertCalendarChannel = MethodChannel(messenger, "com.zarz.spotiflac/concert_calendar").also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
if (call.method != "add") {
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioMixerAttributes
|
||||
import android.media.AudioRouting
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/** Engine-owned (not Activity-owned), so playback survives backgrounding. */
|
||||
class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||
private lateinit var methods: MethodChannel
|
||||
private lateinit var events: EventChannel
|
||||
private var engine: UsbPcmPlayback? = null
|
||||
private var sink: EventChannel.EventSink? = null
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methods = MethodChannel(binding.binaryMessenger, "com.zarz.spotiflac/usb_pcm")
|
||||
events = EventChannel(binding.binaryMessenger, "com.zarz.spotiflac/usb_pcm/events")
|
||||
methods.setMethodCallHandler(this)
|
||||
events.setStreamHandler(this)
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
engine = UsbPcmPlayback(binding.applicationContext) { event ->
|
||||
main.post { sink?.success(event) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val playback = engine
|
||||
if (Build.VERSION.SDK_INT < 34 || playback == null) {
|
||||
result.success(if (call.method == "prepare") mapOf("reason" to "android_version") else null)
|
||||
return
|
||||
}
|
||||
playback.command(call) { value, error ->
|
||||
main.post {
|
||||
if (error == null) result.success(value)
|
||||
else result.error("usb_pcm", error, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { sink = events }
|
||||
override fun onCancel(arguments: Any?) { sink = null }
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methods.setMethodCallHandler(null)
|
||||
events.setStreamHandler(null)
|
||||
sink = null
|
||||
if (Build.VERSION.SDK_INT >= 34) engine?.dispose()
|
||||
engine = null
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(34)
|
||||
private class UsbPcmPlayback(context: Context, private val emit: (Map<String, Any>) -> Unit) {
|
||||
private val manager = context.getSystemService(AudioManager::class.java)
|
||||
private val thread = HandlerThread("SpotiFLAC-USB-PCM").apply { start() }
|
||||
private val worker = Handler(thread.looper)
|
||||
private val wakeLock = context.getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SpotiFLAC:UsbPcm")
|
||||
private val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()
|
||||
private var extractor: MediaExtractor? = null
|
||||
private var decoder: MediaCodec? = null
|
||||
private var track: AudioTrack? = null
|
||||
private var device: AudioDeviceInfo? = null
|
||||
private var mixer: AudioMixerAttributes? = null
|
||||
private var token = 0
|
||||
private var rate = 0
|
||||
private var channels = 0
|
||||
private var sourceBits = 0
|
||||
private var inputBits = 0
|
||||
private var floatInput = false
|
||||
private var outputBits = 0
|
||||
private var durationUs = 0L
|
||||
private var positionBaseUs = 0L
|
||||
private var seekTargetUs = 0L
|
||||
private var needTimestamp = true
|
||||
private var framesWritten = 0L
|
||||
private var previousHead = 0L
|
||||
private var headWrap = 0L
|
||||
private var inputEnded = false
|
||||
private var outputEnded = false
|
||||
private var playing = false
|
||||
private var invalidRoute = false
|
||||
private var nextRouteCheck = 0L
|
||||
private var pending: ByteBuffer? = null
|
||||
private val info = MediaCodec.BufferInfo()
|
||||
private val rawBuffer = ByteBuffer.allocateDirect(256 * 1024)
|
||||
@Volatile private var revision = 0
|
||||
private val routing = AudioRouting.OnRoutingChangedListener {
|
||||
if (playing && track?.routedDevice?.id != device?.id) suspendRoute()
|
||||
}
|
||||
private val mixerListener = AudioManager.OnPreferredMixerAttributesChangedListener { attr, changedDevice, value ->
|
||||
if (attr.usage == AudioAttributes.USAGE_MEDIA && changedDevice.id == device?.id && value != mixer) {
|
||||
suspendRoute()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
manager.addOnPreferredMixerAttributesChangedListener({ runnable -> worker.post(runnable) }, mixerListener)
|
||||
}
|
||||
|
||||
fun command(call: MethodCall, done: (Any?, String?) -> Unit) {
|
||||
val commandRevision = if (call.method == "prepare" || call.method == "stop") ++revision else revision
|
||||
worker.post {
|
||||
try {
|
||||
val value: Any? = when (call.method) {
|
||||
"prepare" -> {
|
||||
closeSource()
|
||||
token = call.argument<Int>("token") ?: 0
|
||||
try {
|
||||
prepare(requireNotNull(call.argument<String>("path")), commandRevision)
|
||||
} catch (error: Exception) {
|
||||
closeSource()
|
||||
android.util.Log.i("UsbPcm", "Using normal output: ${error.message}")
|
||||
mapOf("reason" to (error as? Unavailable)?.reason.orEmpty().ifEmpty { "format" })
|
||||
}
|
||||
}
|
||||
"resume" -> { resume(); null }
|
||||
"pause" -> { pause(); null }
|
||||
"seek" -> { seek((call.argument<Number>("position")?.toLong() ?: 0) * 1000); null }
|
||||
"stop" -> { closeSource(); null }
|
||||
"position" -> positionUs() / 1000
|
||||
else -> error("Unknown USB PCM command")
|
||||
}
|
||||
done(value, null)
|
||||
} catch (error: Exception) { done(null, error.message ?: "USB playback failed") }
|
||||
}
|
||||
}
|
||||
|
||||
private class Unavailable(val reason: String) : Exception(reason)
|
||||
|
||||
private fun prepare(path: String, expectedRevision: Int): Map<String, Any> {
|
||||
val usb = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
|
||||
it.type == AudioDeviceInfo.TYPE_USB_DEVICE || it.type == AudioDeviceInfo.TYPE_USB_HEADSET
|
||||
} ?: throw Unavailable("no_usb")
|
||||
val supported = manager.getSupportedMixerAttributes(usb).filter {
|
||||
it.mixerBehavior == AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT
|
||||
}
|
||||
if (supported.isEmpty()) throw Unavailable("unsupported")
|
||||
// The owned descriptor is duplicated by MediaExtractor; a SAF lease may
|
||||
// close as soon as prepare returns without keeping a full-file copy.
|
||||
val media = MediaExtractor().also { extractor = it }
|
||||
FileInputStream(path).use { media.setDataSource(it.fd) }
|
||||
val index = (0 until media.trackCount).firstOrNull {
|
||||
media.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
||||
} ?: throw Unavailable("format")
|
||||
media.selectTrack(index)
|
||||
val format = media.getTrackFormat(index)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME)
|
||||
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (channels !in 1..2) throw Unavailable("format")
|
||||
val mask = if (channels == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
|
||||
durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0
|
||||
if (mime == "audio/flac") {
|
||||
val header = ByteArray(42)
|
||||
FileInputStream(path).use { input ->
|
||||
var offset = 0
|
||||
while (offset < header.size) {
|
||||
val read = input.read(header, offset, header.size - offset)
|
||||
if (read <= 0) break
|
||||
offset += read
|
||||
}
|
||||
}
|
||||
val source = BitPerfectPcm.flacFormat(header) ?: throw Unavailable("format")
|
||||
if (source.first != rate || source.second != channels) throw Unavailable("format")
|
||||
sourceBits = source.third
|
||||
} else if (mime == "audio/raw" && format.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
|
||||
sourceBits = bits(format.getInteger(MediaFormat.KEY_PCM_ENCODING))
|
||||
} else throw Unavailable("format")
|
||||
if (sourceBits !in listOf(16, 24, 32)) throw Unavailable("format")
|
||||
val matching = supported.filter { it.format.sampleRate == rate && it.format.channelMask == mask }
|
||||
outputBits = BitPerfectPcm.outputBits(sourceBits, matching.map { bits(it.format.encoding) })
|
||||
?: throw Unavailable("format")
|
||||
val selected = matching.first { bits(it.format.encoding) == outputBits }
|
||||
device = usb
|
||||
mixer = selected
|
||||
if (!manager.setPreferredMixerAttributes(attributes, usb, selected)) throw Unavailable("unsupported")
|
||||
val minimum = AudioTrack.getMinBufferSize(rate, mask, selected.format.encoding)
|
||||
if (minimum <= 0) throw Unavailable("format")
|
||||
val audio = AudioTrack.Builder().setAudioAttributes(attributes).setAudioFormat(selected.format)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(maxOf(minimum * 2, rate / 5 * channels * (outputBits / 8)))
|
||||
.build().also { track = it }
|
||||
if (audio.state != AudioTrack.STATE_INITIALIZED || !audio.setPreferredDevice(usb) ||
|
||||
audio.sampleRate != rate || audio.audioFormat != selected.format.encoding ||
|
||||
audio.channelCount != channels) throw Unavailable("format")
|
||||
audio.addOnRoutingChangedListener(routing, worker)
|
||||
// Never change STREAM_MUSIC volume: the DAC owns volume in this mode.
|
||||
audio.setVolume(1f)
|
||||
if (mime == "audio/raw") {
|
||||
inputBits = sourceBits
|
||||
floatInput = false
|
||||
} else {
|
||||
// 24-bit integers are represented exactly by IEEE float. Converting
|
||||
// them back is checked sample-by-sample, with no rounding allowed.
|
||||
format.setInteger(MediaFormat.KEY_PCM_ENCODING, when (sourceBits) {
|
||||
16 -> AudioFormat.ENCODING_PCM_16BIT
|
||||
24 -> AudioFormat.ENCODING_PCM_FLOAT
|
||||
else -> AudioFormat.ENCODING_PCM_32BIT
|
||||
})
|
||||
val codec = MediaCodec.createDecoderByType(requireNotNull(mime)).also { decoder = it }
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
inputBits = 0
|
||||
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
|
||||
while (pending == null && !outputEnded) {
|
||||
if (revision != expectedRevision) throw Unavailable("cancelled")
|
||||
if (android.os.SystemClock.elapsedRealtime() > deadline) throw Unavailable("format")
|
||||
decode()
|
||||
if (pending == null) Thread.sleep(2)
|
||||
}
|
||||
}
|
||||
return mapOf("ready" to true, "device" to usb.productName.toString(), "sampleRate" to rate,
|
||||
"bitDepth" to outputBits, "sourceBitDepth" to sourceBits, "duration" to durationUs / 1000)
|
||||
}
|
||||
|
||||
private fun bits(encoding: Int): Int = when (encoding) {
|
||||
AudioFormat.ENCODING_PCM_16BIT -> 16
|
||||
AudioFormat.ENCODING_PCM_24BIT_PACKED -> 24
|
||||
AudioFormat.ENCODING_PCM_32BIT -> 32
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun acceptFormat(format: MediaFormat) {
|
||||
if (format.getInteger(MediaFormat.KEY_SAMPLE_RATE) != rate ||
|
||||
format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) != channels) throw Unavailable("format")
|
||||
val encoding = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING))
|
||||
format.getInteger(MediaFormat.KEY_PCM_ENCODING) else AudioFormat.ENCODING_PCM_16BIT
|
||||
floatInput = encoding == AudioFormat.ENCODING_PCM_FLOAT
|
||||
inputBits = if (floatInput) 32 else bits(encoding)
|
||||
if (inputBits < sourceBits || (floatInput && sourceBits > 24)) throw Unavailable("format")
|
||||
}
|
||||
|
||||
private fun pcm(input: ByteBuffer, timeUs: Long) {
|
||||
val frameBytes = channels * (inputBits / 8)
|
||||
require(frameBytes > 0 && input.remaining() % frameBytes == 0)
|
||||
val skipFrames = if (timeUs < seekTargetUs)
|
||||
((seekTargetUs - timeUs) * rate + 999999) / 1000000 else 0
|
||||
val skipped = minOf(skipFrames, (input.remaining() / frameBytes).toLong())
|
||||
input.position(input.position() + skipped.toInt() * frameBytes)
|
||||
if (!input.hasRemaining()) return
|
||||
if (needTimestamp) {
|
||||
positionBaseUs = timeUs + skipped * 1000000 / rate
|
||||
needTimestamp = false
|
||||
}
|
||||
pending = BitPerfectPcm.convert(input, inputBits, sourceBits, outputBits, floatInput)
|
||||
}
|
||||
|
||||
private fun decode() {
|
||||
val media = extractor ?: return
|
||||
if (pending != null || outputEnded) return
|
||||
val codec = decoder
|
||||
if (codec == null) {
|
||||
rawBuffer.clear()
|
||||
val size = media.readSampleData(rawBuffer, 0)
|
||||
if (size < 0) { outputEnded = true; return }
|
||||
rawBuffer.position(0)
|
||||
rawBuffer.limit(size)
|
||||
pcm(rawBuffer, media.sampleTime)
|
||||
media.advance()
|
||||
return
|
||||
}
|
||||
// Keep the codec fed independently of FLAC block size. Processing only
|
||||
// one tiny frame per pump would starve a high-rate DAC.
|
||||
for (attempt in 0 until 8) {
|
||||
if (inputEnded) break
|
||||
val slot = codec.dequeueInputBuffer(0)
|
||||
if (slot < 0) break
|
||||
val buffer = requireNotNull(codec.getInputBuffer(slot))
|
||||
val size = media.readSampleData(buffer, 0)
|
||||
if (size < 0) {
|
||||
codec.queueInputBuffer(slot, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
inputEnded = true
|
||||
} else {
|
||||
codec.queueInputBuffer(slot, 0, size, media.sampleTime, 0)
|
||||
media.advance()
|
||||
}
|
||||
}
|
||||
val slot = codec.dequeueOutputBuffer(info, 0)
|
||||
if (slot == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) acceptFormat(codec.outputFormat)
|
||||
if (slot >= 0) {
|
||||
try {
|
||||
if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
||||
if (inputBits == 0) acceptFormat(codec.outputFormat)
|
||||
val buffer = requireNotNull(codec.getOutputBuffer(slot))
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
pcm(buffer, info.presentationTimeUs)
|
||||
}
|
||||
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
} finally { codec.releaseOutputBuffer(slot, false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifiedRoute(): Boolean = track?.routedDevice?.id == device?.id &&
|
||||
device?.let { manager.getPreferredMixerAttributes(attributes, it) == mixer } == true
|
||||
|
||||
private fun resume() {
|
||||
val audio = track ?: error("No USB source")
|
||||
check(!invalidRoute) { "USB route changed; reopen the track" }
|
||||
audio.play()
|
||||
// Do not write any samples until the route actually resolves to our DAC.
|
||||
if (!verifiedRoute()) {
|
||||
audio.pause()
|
||||
invalidRoute = true
|
||||
emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed"))
|
||||
error("USB route could not be verified")
|
||||
}
|
||||
playing = true
|
||||
if (!wakeLock.isHeld) wakeLock.acquire()
|
||||
emit(mapOf("token" to token, "event" to "active"))
|
||||
worker.removeCallbacks(pump)
|
||||
worker.post(pump)
|
||||
}
|
||||
|
||||
private val pump = object : Runnable {
|
||||
override fun run() {
|
||||
if (!playing) return
|
||||
try {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now >= nextRouteCheck) {
|
||||
if (!verifiedRoute()) { suspendRoute(); return }
|
||||
nextRouteCheck = now + 250
|
||||
}
|
||||
// Bounded nonblocking writes keep transport commands responsive
|
||||
// even when a DAC stops consuming samples.
|
||||
for (attempt in 0 until 8) {
|
||||
decode()
|
||||
val buffer = pending ?: break
|
||||
val written = requireNotNull(track).write(buffer, buffer.remaining(), AudioTrack.WRITE_NON_BLOCKING)
|
||||
check(written >= 0) { "USB write failed: $written" }
|
||||
framesWritten += written / (channels * (outputBits / 8))
|
||||
if (buffer.hasRemaining()) break
|
||||
pending = null
|
||||
}
|
||||
if (outputEnded && pending == null && headFrames() >= framesWritten) {
|
||||
pause()
|
||||
emit(mapOf("token" to token, "event" to "complete"))
|
||||
} else worker.postDelayed(this, 5)
|
||||
} catch (error: Exception) {
|
||||
android.util.Log.w("UsbPcm", "PCM playback stopped", error)
|
||||
suspendRoute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
playing = false
|
||||
worker.removeCallbacks(pump)
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
runCatching {
|
||||
if (track?.playState == AudioTrack.PLAYSTATE_PLAYING) track?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private fun suspendRoute() {
|
||||
pause()
|
||||
invalidRoute = true
|
||||
emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed"))
|
||||
}
|
||||
|
||||
private fun headFrames(): Long {
|
||||
val head = (track?.playbackHeadPosition?.toLong() ?: 0) and 0xffffffffL
|
||||
if (head < previousHead) headWrap += 1L shl 32
|
||||
previousHead = head
|
||||
return headWrap + head
|
||||
}
|
||||
private fun positionUs(): Long = positionBaseUs + if (rate > 0) headFrames() * 1000000 / rate else 0
|
||||
|
||||
private fun seek(positionUs: Long) {
|
||||
val wasPlaying = playing
|
||||
pause()
|
||||
track?.flush()
|
||||
decoder?.flush()
|
||||
seekTargetUs = if (durationUs > 0) positionUs.coerceIn(0, durationUs - 1)
|
||||
else positionUs.coerceAtLeast(0)
|
||||
extractor?.seekTo(seekTargetUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
positionBaseUs = seekTargetUs
|
||||
framesWritten = 0
|
||||
previousHead = 0
|
||||
headWrap = 0
|
||||
needTimestamp = true
|
||||
pending = null
|
||||
inputEnded = false
|
||||
outputEnded = false
|
||||
if (wasPlaying) resume()
|
||||
}
|
||||
|
||||
private fun closeSource() {
|
||||
pause()
|
||||
track?.let { audio ->
|
||||
runCatching { audio.removeOnRoutingChangedListener(routing) }
|
||||
runCatching { audio.release() }
|
||||
}
|
||||
track = null
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
runCatching { extractor?.release() }
|
||||
extractor = null
|
||||
device?.let { runCatching { manager.clearPreferredMixerAttributes(attributes, it) } }
|
||||
device = null
|
||||
mixer = null
|
||||
pending = null
|
||||
framesWritten = 0
|
||||
previousHead = 0
|
||||
headWrap = 0
|
||||
positionBaseUs = 0
|
||||
seekTargetUs = 0
|
||||
durationUs = 0
|
||||
inputEnded = false
|
||||
outputEnded = false
|
||||
needTimestamp = true
|
||||
invalidRoute = false
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
revision++
|
||||
worker.post {
|
||||
closeSource()
|
||||
manager.removeOnPreferredMixerAttributesChangedListener(mixerListener)
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class BitPerfectPcmTest {
|
||||
private fun bytes(buffer: ByteBuffer): ByteArray = ByteArray(buffer.remaining()).also { buffer.get(it) }
|
||||
|
||||
@Test
|
||||
fun selectsOnlyAnIntegerFormatWithEnoughPrecision() {
|
||||
assertEquals(24, BitPerfectPcm.outputBits(24, listOf(16, 32, 24)))
|
||||
assertEquals(32, BitPerfectPcm.outputBits(24, listOf(16, 32)))
|
||||
assertNull(BitPerfectPcm.outputBits(24, listOf(16)))
|
||||
assertNull(BitPerfectPcm.outputBits(32, listOf(16, 24)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesPacked24BitSamplesIncludingSignExtremes() {
|
||||
val samples = byteArrayOf(0, 0, -128, -1, -1, 127, -1, -1, -1, 1, 0, 0)
|
||||
val input = ByteBuffer.wrap(samples)
|
||||
assertArrayEquals(samples, bytes(BitPerfectPcm.convert(input, 24, 24, 24, false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun padsLeastSignificantBitsWithoutChangingSampleAmplitude() {
|
||||
val input = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN)
|
||||
.putShort(Short.MIN_VALUE).putShort(Short.MAX_VALUE).putShort(-1)
|
||||
input.flip()
|
||||
val result = BitPerfectPcm.convert(input, 16, 16, 32, false)
|
||||
assertEquals(Int.MIN_VALUE, result.int)
|
||||
assertEquals(0x7fff0000, result.int)
|
||||
assertEquals(-65536, result.int)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderFloatRoundTripPreservesEveryBitOf24BitSamples() {
|
||||
val random = Random(17)
|
||||
val samples = listOf(-8388608, 8388607, -1, 0, 1) + List(10000) { random.nextInt(-8388608, 8388608) }
|
||||
val input = ByteBuffer.allocate(samples.size * 4).order(ByteOrder.LITTLE_ENDIAN)
|
||||
for (sample in samples) input.putFloat(sample / 8388608f)
|
||||
input.flip()
|
||||
val result = BitPerfectPcm.convert(input, 32, 24, 32, true)
|
||||
for (sample in samples) assertEquals(sample shl 8, result.int)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun rejectsDecoderDownconversion() {
|
||||
BitPerfectPcm.convert(ByteBuffer.allocate(2), 16, 24, 24, false)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun rejectsFloatingPointFor32BitSource() {
|
||||
BitPerfectPcm.convert(ByteBuffer.allocate(4), 32, 32, 32, true)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun refusesRoundingOfNonRepresentableFloatSamples() {
|
||||
val input = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putFloat(0.00000001f)
|
||||
input.flip()
|
||||
BitPerfectPcm.convert(input, 32, 24, 24, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsFlacPrecisionFromStreaminfoInsteadOfLibraryTags() {
|
||||
val header = ByteArray(42)
|
||||
"fLaC".toByteArray().copyInto(header)
|
||||
header[4] = -128
|
||||
header[7] = 34
|
||||
val rate = 96000
|
||||
val packed = (rate.toLong() shl 44) or (1L shl 41) or (23L shl 36)
|
||||
for (i in 0..7) header[18 + i] = (packed ushr (56 - i * 8)).toByte()
|
||||
assertEquals(Triple(96000, 2, 24), BitPerfectPcm.flacFormat(header))
|
||||
header[7] = 33
|
||||
assertNull(BitPerfectPcm.flacFormat(header))
|
||||
assertNull(BitPerfectPcm.flacFormat(ByteArray(4)))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"usbBitPerfect": "USB bit-perfect audio",
|
||||
"usbBitPerfectDescription": "Preserve the original PCM audio on compatible USB DACs with Android 14 or later. Applies from the next track, bypassing AutoMix and normalization. Control volume on your DAC; system volume may not apply.",
|
||||
"usbBitPerfectPending": "Waiting for the next track and a compatible USB output.",
|
||||
"usbBitPerfectNoDevice": "No USB DAC connected. Using normal audio output.",
|
||||
"usbBitPerfectFallback": "This device or audio format does not support bit-perfect output. Using normal playback.",
|
||||
"usbBitPerfectRouteChanged": "The USB audio route changed. Playback paused; select a track to reconnect.",
|
||||
"upgradeIntroWelcome": "Welcome to\nSpotiFLAC-Mobile 5.0",
|
||||
"upgradeIntroThemeTitle": "Make it\nyour own.",
|
||||
"upgradeIntroThemeBody": "Choose a look. Your choice applies immediately, and you can change it later in Appearance.",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"usbBitPerfect": "Audio USB bit-perfect",
|
||||
"usbBitPerfectDescription": "Pertahankan PCM asli pada DAC USB yang kompatibel dengan Android 14 atau lebih baru. Berlaku mulai lagu berikutnya, tanpa AutoMix dan normalisasi. Atur volume melalui DAC; volume sistem mungkin tidak berlaku.",
|
||||
"usbBitPerfectPending": "Menunggu lagu berikutnya dan keluaran USB yang kompatibel.",
|
||||
"usbBitPerfectNoDevice": "DAC USB tidak terhubung. Menggunakan keluaran audio biasa.",
|
||||
"usbBitPerfectFallback": "Perangkat atau format audio ini tidak mendukung keluaran bit-perfect. Menggunakan pemutaran biasa.",
|
||||
"usbBitPerfectRouteChanged": "Keluaran audio USB berubah. Pemutaran dijeda; pilih lagu untuk menyambungkan kembali.",
|
||||
"upgradeIntroWelcome": "Selamat datang di\nSpotiFLAC-Mobile 5.0",
|
||||
"upgradeIntroThemeTitle": "Pilih\ntampilanmu.",
|
||||
"upgradeIntroThemeBody": "Pilih tema yang kamu suka. Pilihan langsung diterapkan dan bisa diubah lagi lewat Tampilan.",
|
||||
|
||||
@@ -32,6 +32,7 @@ class AppSettings {
|
||||
// Apply ReplayGain/R128 tags as volume normalization in the built-in player.
|
||||
final bool playbackNormalization;
|
||||
final bool autoMix;
|
||||
final bool usbBitPerfect;
|
||||
final bool autoplay;
|
||||
final bool isFirstLaunch;
|
||||
final bool checkForUpdates;
|
||||
@@ -145,6 +146,7 @@ class AppSettings {
|
||||
this.embedReplayGain = false,
|
||||
this.playbackNormalization = false,
|
||||
this.autoMix = false,
|
||||
this.usbBitPerfect = false,
|
||||
this.autoplay = false,
|
||||
this.isFirstLaunch = true,
|
||||
this.checkForUpdates = true,
|
||||
@@ -233,6 +235,7 @@ class AppSettings {
|
||||
bool? embedReplayGain,
|
||||
bool? playbackNormalization,
|
||||
bool? autoMix,
|
||||
bool? usbBitPerfect,
|
||||
bool? autoplay,
|
||||
bool? isFirstLaunch,
|
||||
bool? checkForUpdates,
|
||||
@@ -317,6 +320,7 @@ class AppSettings {
|
||||
playbackNormalization:
|
||||
playbackNormalization ?? this.playbackNormalization,
|
||||
autoMix: autoMix ?? this.autoMix,
|
||||
usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect,
|
||||
autoplay: autoplay ?? this.autoplay,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
|
||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
|
||||
playbackNormalization: json['playbackNormalization'] as bool? ?? false,
|
||||
autoMix: json['autoMix'] as bool? ?? false,
|
||||
usbBitPerfect: json['usbBitPerfect'] as bool? ?? false,
|
||||
autoplay: json['autoplay'] as bool? ?? false,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
@@ -123,6 +124,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'embedReplayGain': instance.embedReplayGain,
|
||||
'playbackNormalization': instance.playbackNormalization,
|
||||
'autoMix': instance.autoMix,
|
||||
'usbBitPerfect': instance.usbBitPerfect,
|
||||
'autoplay': instance.autoplay,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
|
||||
@@ -561,6 +561,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUsbBitPerfect(bool enabled) {
|
||||
state = state.copyWith(usbBitPerfect: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -92,6 +92,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
ref.read(settingsProvider).playbackNormalization,
|
||||
);
|
||||
setAutoMixEnabled(ref.read(settingsProvider).autoMix);
|
||||
setUsbBitPerfectEnabled(ref.read(settingsProvider).usbBitPerfect);
|
||||
setAutoplayEnabled(
|
||||
ref.read(settingsProvider).autoplay,
|
||||
includeLocal: ref.read(settingsProvider).localLibraryEnabled,
|
||||
@@ -742,6 +743,9 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) {
|
||||
setAutoMixEnabled(enabled);
|
||||
});
|
||||
ref.listen(settingsProvider.select((s) => s.usbBitPerfect), (_, enabled) {
|
||||
setUsbBitPerfectEnabled(enabled);
|
||||
});
|
||||
ref.listen(
|
||||
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
|
||||
(_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart';
|
||||
@@ -880,6 +881,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
title: context.l10n.libraryPlaybackNormalization,
|
||||
subtitle: context.l10n.libraryPlaybackNormalizationSubtitle,
|
||||
value: settings.playbackNormalization,
|
||||
enabled: !settings.usbBitPerfect,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setPlaybackNormalization(v),
|
||||
@@ -889,10 +891,39 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
title: 'AutoMix',
|
||||
subtitle: context.l10n.autoMixDescription,
|
||||
value: settings.autoMix,
|
||||
enabled: !settings.usbBitPerfect,
|
||||
onChanged: (value) =>
|
||||
ref.read(settingsProvider.notifier).setAutoMix(value),
|
||||
showDivider: false,
|
||||
showDivider: Platform.isAndroid,
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
ValueListenableBuilder<UsbAudioStatus>(
|
||||
valueListenable: usbAudioStatus,
|
||||
builder: (context, status, _) => SettingsSwitchItem(
|
||||
icon: Icons.usb,
|
||||
title: context.l10n.usbBitPerfect,
|
||||
subtitle: [
|
||||
context.l10n.usbBitPerfectDescription,
|
||||
if (settings.usbBitPerfect)
|
||||
switch (status.reason) {
|
||||
'active' =>
|
||||
'${status.device} · ${status.bitDepth}-bit · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz',
|
||||
'no_usb' => context.l10n.usbBitPerfectNoDevice,
|
||||
'route_changed' =>
|
||||
context.l10n.usbBitPerfectRouteChanged,
|
||||
'android_version' ||
|
||||
'unsupported' ||
|
||||
'format' => context.l10n.usbBitPerfectFallback,
|
||||
_ => context.l10n.usbBitPerfectPending,
|
||||
},
|
||||
].join('\n\n'),
|
||||
value: settings.usbBitPerfect,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsbBitPerfect(value),
|
||||
showDivider: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Only verified native playback reports active. File metadata alone never does.
|
||||
final usbAudioStatus = ValueNotifier<UsbAudioStatus>(const UsbAudioStatus());
|
||||
|
||||
class UsbAudioStatus {
|
||||
const UsbAudioStatus({
|
||||
this.reason = 'idle',
|
||||
this.device,
|
||||
this.sampleRate,
|
||||
this.bitDepth,
|
||||
});
|
||||
|
||||
final String reason;
|
||||
final String? device;
|
||||
final int? sampleRate;
|
||||
final int? bitDepth;
|
||||
}
|
||||
|
||||
/// Keeps the existing transport and AutoMix API while adding a local USB path.
|
||||
/// Each prepare owns a token so delayed native events cannot affect a new song.
|
||||
class MusicPlaybackDeck {
|
||||
MusicPlaybackDeck({required String playerId, bool? nativeAvailable})
|
||||
: _ordinary = AudioPlayer(playerId: playerId),
|
||||
_nativeAvailable = nativeAvailable ?? Platform.isAndroid {
|
||||
_subscriptions.addAll([
|
||||
_ordinary.onPlayerStateChanged.listen((value) {
|
||||
if (!_direct) _states.add(value);
|
||||
}),
|
||||
_ordinary.onDurationChanged.listen((value) {
|
||||
if (!_direct) _durations.add(value);
|
||||
}),
|
||||
_ordinary.onPlayerComplete.listen((_) {
|
||||
if (!_direct) _completions.add(null);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/usb_pcm');
|
||||
static const _events = EventChannel('com.zarz.spotiflac/usb_pcm/events');
|
||||
static Stream<dynamic>? _nativeEvents;
|
||||
static int _nextToken = 0;
|
||||
final AudioPlayer _ordinary;
|
||||
final bool _nativeAvailable;
|
||||
final _states = StreamController<PlayerState>.broadcast();
|
||||
final _durations = StreamController<Duration>.broadcast();
|
||||
final _positions = StreamController<Duration>.broadcast();
|
||||
final _completions = StreamController<void>.broadcast();
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
StreamSubscription<dynamic>? _nativeSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
Timer? _timer;
|
||||
bool _polling = false;
|
||||
bool _direct = false;
|
||||
bool _disposed = false;
|
||||
int _token = 0;
|
||||
Duration? _duration;
|
||||
DeviceFileSource? _source;
|
||||
bool _routeLost = false;
|
||||
bool _nativeStarted = false;
|
||||
PlayerState _directState = PlayerState.stopped;
|
||||
UsbAudioStatus _preparedStatus = const UsbAudioStatus();
|
||||
|
||||
bool get isDirect => _direct;
|
||||
bool get needsSourceReload => _direct && _routeLost;
|
||||
PlayerState get state => _direct ? _directState : _ordinary.state;
|
||||
Stream<PlayerState> get onPlayerStateChanged => _states.stream;
|
||||
Stream<Duration> get onDurationChanged => _durations.stream;
|
||||
Stream<Duration> get onPositionChanged => _positions.stream;
|
||||
Stream<void> get onPlayerComplete => _completions.stream;
|
||||
|
||||
set positionUpdater(PositionUpdater updater) {
|
||||
_ordinary.positionUpdater = updater;
|
||||
unawaited(_positionSubscription?.cancel());
|
||||
_positionSubscription = _ordinary.onPositionChanged.listen((value) {
|
||||
if (!_direct) _positions.add(value);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setReleaseMode(ReleaseMode mode) =>
|
||||
_ordinary.setReleaseMode(mode);
|
||||
Future<void> setAudioContext(AudioContext context) =>
|
||||
_ordinary.setAudioContext(context);
|
||||
Future<void> setVolume(double volume) async {
|
||||
if (!_direct) await _ordinary.setVolume(volume);
|
||||
}
|
||||
|
||||
Future<void> setPlaybackRate(double rate) async {
|
||||
if (!_direct) await _ordinary.setPlaybackRate(rate);
|
||||
}
|
||||
|
||||
Future<void> setSource(
|
||||
DeviceFileSource source, {
|
||||
bool preferBitPerfect = false,
|
||||
}) async {
|
||||
await stop();
|
||||
_source = source;
|
||||
_routeLost = false;
|
||||
_nativeStarted = false;
|
||||
if (preferBitPerfect && _nativeAvailable) {
|
||||
_token = ++_nextToken;
|
||||
_nativeSubscription ??=
|
||||
(_nativeEvents ??= _events.receiveBroadcastStream()).listen(
|
||||
_onNativeEvent,
|
||||
onError: (Object error) {
|
||||
if (_direct && !_disposed) {
|
||||
_routeLost = true;
|
||||
unawaited(pause().catchError((Object _) {}));
|
||||
usbAudioStatus.value = const UsbAudioStatus(
|
||||
reason: 'route_changed',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
try {
|
||||
final response = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'prepare',
|
||||
{'path': source.path, 'token': _token},
|
||||
);
|
||||
if (response?['ready'] == true) {
|
||||
_direct = true;
|
||||
_directState = PlayerState.stopped;
|
||||
_duration = Duration(
|
||||
milliseconds: (response?['duration'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
_preparedStatus = UsbAudioStatus(
|
||||
reason: 'active',
|
||||
device: response?['device'] as String?,
|
||||
sampleRate: (response?['sampleRate'] as num?)?.toInt(),
|
||||
bitDepth: (response?['bitDepth'] as num?)?.toInt(),
|
||||
);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'ready');
|
||||
_durations.add(_duration!);
|
||||
return;
|
||||
}
|
||||
usbAudioStatus.value = UsbAudioStatus(
|
||||
reason: response?['reason'] as String? ?? 'unsupported',
|
||||
);
|
||||
} on PlatformException {
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
|
||||
} on MissingPluginException {
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
|
||||
}
|
||||
}
|
||||
await _ordinary.setSource(source);
|
||||
}
|
||||
|
||||
void _onNativeEvent(dynamic event) {
|
||||
if (_disposed || !_direct || event is! Map || event['token'] != _token) {
|
||||
return;
|
||||
}
|
||||
switch (event['event']) {
|
||||
case 'active':
|
||||
usbAudioStatus.value = _preparedStatus;
|
||||
case 'paused':
|
||||
_routeLost = true;
|
||||
_timer?.cancel();
|
||||
_setState(PlayerState.paused);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
|
||||
case 'complete':
|
||||
_timer?.cancel();
|
||||
_setState(PlayerState.completed);
|
||||
_completions.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _setState(PlayerState value) {
|
||||
_directState = value;
|
||||
if (!_disposed) _states.add(value);
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
if (!_direct) return _ordinary.resume();
|
||||
final token = _token;
|
||||
// Before first playback the SAF lease is still open. After playback starts,
|
||||
// reconnect through the handler to obtain a fresh lease for the same song.
|
||||
try {
|
||||
if (_routeLost) throw PlatformException(code: 'route_changed');
|
||||
await _channel.invokeMethod<void>('resume');
|
||||
} on PlatformException {
|
||||
if (token != _token || !_direct || _disposed) return;
|
||||
if (_nativeStarted) {
|
||||
_routeLost = true;
|
||||
_setState(PlayerState.paused);
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
|
||||
rethrow;
|
||||
}
|
||||
final source = _source;
|
||||
final position = await getCurrentPosition() ?? Duration.zero;
|
||||
await stop();
|
||||
usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported');
|
||||
if (source == null || _disposed) return;
|
||||
await _ordinary.setVolume(1);
|
||||
await _ordinary.setSource(source);
|
||||
if (position > Duration.zero) await _ordinary.seek(position);
|
||||
return _ordinary.resume();
|
||||
}
|
||||
if (_disposed || token != _token || !_direct) return;
|
||||
_nativeStarted = true;
|
||||
_setState(PlayerState.playing);
|
||||
usbAudioStatus.value = _preparedStatus;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) async {
|
||||
if (_polling || _disposed) return;
|
||||
_polling = true;
|
||||
final token = _token;
|
||||
try {
|
||||
final position = await getCurrentPosition();
|
||||
if (!_disposed && _direct && token == _token && position != null) {
|
||||
_positions.add(position);
|
||||
}
|
||||
} on PlatformException {
|
||||
// Route failures arrive through the native event stream.
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
if (!_direct) return _ordinary.pause();
|
||||
final token = _token;
|
||||
_timer?.cancel();
|
||||
await _channel.invokeMethod<void>('pause');
|
||||
if (token == _token && _direct) _setState(PlayerState.paused);
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
if (!_direct) return _ordinary.seek(position);
|
||||
final token = _token;
|
||||
await _channel.invokeMethod<void>('seek', {
|
||||
'position': position.inMilliseconds,
|
||||
});
|
||||
if (!_disposed && token == _token && _direct) _positions.add(position);
|
||||
}
|
||||
|
||||
Future<Duration?> getCurrentPosition() async {
|
||||
if (!_direct) return _ordinary.getCurrentPosition();
|
||||
final milliseconds = await _channel.invokeMethod<int>('position');
|
||||
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
|
||||
}
|
||||
|
||||
Future<Duration?> getDuration() =>
|
||||
_direct ? Future.value(_duration) : _ordinary.getDuration();
|
||||
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
if (_direct) {
|
||||
_token = ++_nextToken;
|
||||
await _channel.invokeMethod<void>('stop');
|
||||
_setState(PlayerState.stopped);
|
||||
_direct = false;
|
||||
usbAudioStatus.value = const UsbAudioStatus();
|
||||
}
|
||||
await _ordinary.stop();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await stop();
|
||||
_disposed = true;
|
||||
await _nativeSubscription?.cancel();
|
||||
await _positionSubscription?.cancel();
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
await _ordinary.dispose();
|
||||
await _states.close();
|
||||
await _durations.close();
|
||||
await _positions.close();
|
||||
await _completions.close();
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ class _MusicAutoMix {
|
||||
final AutoMixAnalyzer analyzer;
|
||||
final _preparationPins = <int, Set<String>>{};
|
||||
final _mixPins = <String>{};
|
||||
AudioPlayer? _prepared;
|
||||
AudioPlayer? _outgoing;
|
||||
MusicPlaybackDeck? _prepared;
|
||||
MusicPlaybackDeck? _outgoing;
|
||||
AutoMixPlan? _plan;
|
||||
PlayableMedia? _next;
|
||||
String? _nextPath;
|
||||
@@ -37,6 +37,8 @@ class _MusicAutoMix {
|
||||
|
||||
bool get _canMix =>
|
||||
_autoMixEnabled &&
|
||||
!_usbBitPerfectEnabled &&
|
||||
!handler._player.isDirect &&
|
||||
!handler._disposed &&
|
||||
handler._sourceReady &&
|
||||
!handler._userPaused &&
|
||||
@@ -91,7 +93,7 @@ class _MusicAutoMix {
|
||||
: -1;
|
||||
final pins = <String>{};
|
||||
_preparationPins[generation] = pins;
|
||||
AudioPlayer? deck;
|
||||
MusicPlaybackDeck? deck;
|
||||
try {
|
||||
if (nextIndex < 0 || nextIndex == index || index < 0) return;
|
||||
final current = handler._media[index];
|
||||
@@ -102,7 +104,7 @@ class _MusicAutoMix {
|
||||
final nextPath = await handler._resolveSource(next);
|
||||
if (nextPath == null || !_current(generation)) return;
|
||||
pins.add(nextPath);
|
||||
deck = AudioPlayer(
|
||||
deck = MusicPlaybackDeck(
|
||||
playerId:
|
||||
'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/sqlite_helpers.dart'
|
||||
show normalizeLookupText;
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/playback_normalization.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/automix_analysis.dart';
|
||||
import 'package:spotiflac_android/services/automix_analyzer.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
@@ -42,6 +43,7 @@ void updateMusicPlayerStrings({
|
||||
|
||||
bool _playbackNormalizationEnabled = false;
|
||||
bool _autoMixEnabled = false;
|
||||
bool _usbBitPerfectEnabled = false;
|
||||
MusicPlayerHandler? _activeMusicPlayerHandler;
|
||||
PlaybackNotification _notificationPresentation = const PlaybackNotification();
|
||||
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
|
||||
@@ -99,6 +101,14 @@ void setAutoMixEnabled(bool enabled) {
|
||||
if (handler != null) unawaited(handler._autoMix.cancel());
|
||||
}
|
||||
|
||||
void setUsbBitPerfectEnabled(bool enabled) {
|
||||
if (_usbBitPerfectEnabled == enabled) return;
|
||||
_usbBitPerfectEnabled = enabled;
|
||||
final handler = _activeMusicPlayerHandler;
|
||||
if (handler != null) unawaited(handler._autoMix.cancel());
|
||||
// Apply at the next source boundary; never raise a playing track's volume.
|
||||
}
|
||||
|
||||
/// Refreshes gain tags after a successful file update, including SAF copies.
|
||||
void refreshPlaybackNormalization(String source) {
|
||||
final handler = _activeMusicPlayerHandler;
|
||||
@@ -352,13 +362,13 @@ Duration normalizedPlaybackResumePosition(
|
||||
|
||||
class MusicPlayerHandler extends BaseAudioHandler
|
||||
with QueueHandler, SeekHandler {
|
||||
AudioPlayer _player = AudioPlayer(playerId: 'music-player');
|
||||
MusicPlaybackDeck _player = MusicPlaybackDeck(playerId: 'music-player');
|
||||
late final _MusicAutoMix _autoMix;
|
||||
late final _MusicAutoplay _autoplay;
|
||||
bool _completing = false;
|
||||
double _normalizationVolume = 1;
|
||||
final _playerSubscriptions =
|
||||
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
|
||||
<MusicPlaybackDeck, List<StreamSubscription<dynamic>>>{};
|
||||
AudioSession? _audioSession;
|
||||
final List<PlayableMedia> _media = [];
|
||||
final List<MediaItem> _queueItems = [];
|
||||
@@ -433,7 +443,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_listenToPlayer(_player);
|
||||
}
|
||||
|
||||
void _listenToPlayer(AudioPlayer player) {
|
||||
void _listenToPlayer(MusicPlaybackDeck player) {
|
||||
// Position updates must continue with the display asleep. UI progress is
|
||||
// interpolated between updates; querying native playback every frame is
|
||||
// unnecessary and would stop preparing AutoMix when there are no frames.
|
||||
@@ -444,6 +454,10 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_playerSubscriptions[player] = [
|
||||
player.onPlayerStateChanged.listen((state) {
|
||||
if (!identical(player, _player)) return;
|
||||
if (state == PlayerState.paused && player.needsSourceReload) {
|
||||
_userPaused = true;
|
||||
_pausedByInterruption = false;
|
||||
}
|
||||
if (_switchingGeneration != 0 &&
|
||||
(state == PlayerState.stopped ||
|
||||
state == PlayerState.completed ||
|
||||
@@ -474,7 +488,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _disposeDeck(AudioPlayer player) async {
|
||||
Future<void> _disposeDeck(MusicPlaybackDeck player) async {
|
||||
final subscriptions = _playerSubscriptions.remove(player);
|
||||
if (subscriptions == null) return;
|
||||
for (final subscription in subscriptions) {
|
||||
@@ -764,7 +778,11 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
String? cacheKey,
|
||||
String? displayName,
|
||||
}) async {
|
||||
if (!_playbackNormalizationEnabled) return 1.0;
|
||||
if (!_playbackNormalizationEnabled ||
|
||||
_usbBitPerfectEnabled ||
|
||||
_player.isDirect) {
|
||||
return 1.0;
|
||||
}
|
||||
return _normalizationCache.volumeFor(
|
||||
path,
|
||||
cacheKey: cacheKey,
|
||||
@@ -1347,7 +1365,10 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
// AudioPlayer.play combines prepare/seek/resume without a cancellation
|
||||
// boundary. A late prepare from an old request could resume the wrong
|
||||
// source. Serialize preparation and recheck before making it audible.
|
||||
await _player.setSource(DeviceFileSource(path));
|
||||
await _player.setSource(
|
||||
DeviceFileSource(path),
|
||||
preferBitPerfect: _usbBitPerfectEnabled,
|
||||
);
|
||||
if (!_isCurrentPlayRequest(generation, media)) return;
|
||||
if (position != null) {
|
||||
await _player.seek(position);
|
||||
@@ -1494,7 +1515,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
mediaItem.add(media.toMediaItem(resolvedSource: resolved));
|
||||
}
|
||||
_broadcastPosition(effectiveStartPosition, force: true);
|
||||
_broadcastState(playerState: PlayerState.playing);
|
||||
_broadcastState();
|
||||
_lastPeriodicPersistAt = DateTime.now();
|
||||
unawaited(_persistSession(position: effectiveStartPosition));
|
||||
// Some files do not emit onDurationChanged reliably (stuck at 0:00);
|
||||
@@ -1613,6 +1634,15 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_pausedByInterruption = false;
|
||||
_interruptionActive = false;
|
||||
_userPaused = false;
|
||||
if (_player.needsSourceReload) {
|
||||
final generation = _playRequestGeneration;
|
||||
final index = _index;
|
||||
final position =
|
||||
await _player.getCurrentPosition() ?? playbackState.value.position;
|
||||
if (generation != _playRequestGeneration || _disposed) return;
|
||||
await _playIndex(index, recordHistory: false, startPosition: position);
|
||||
return;
|
||||
}
|
||||
if ((!_sourceReady ||
|
||||
_player.state == PlayerState.stopped ||
|
||||
_player.state == PlayerState.completed) &&
|
||||
@@ -1627,7 +1657,13 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
}
|
||||
await _activateAudioSession();
|
||||
await _claimHardwareMediaButtons();
|
||||
await _player.resume();
|
||||
try {
|
||||
await _player.resume();
|
||||
} on PlatformException {
|
||||
if (!_player.needsSourceReload) rethrow;
|
||||
_broadcastState(playerState: PlayerState.paused);
|
||||
return;
|
||||
}
|
||||
_broadcastState(playerState: PlayerState.playing);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/automix_analysis.dart';
|
||||
import 'package:spotiflac_android/services/automix_analyzer.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/playback_notification.dart';
|
||||
|
||||
class _Analyzer extends AutoMixAnalyzer {
|
||||
@@ -144,6 +147,7 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
setAutoMixEnabled(false);
|
||||
setUsbBitPerfectEnabled(false);
|
||||
setAutoplayEnabled(false);
|
||||
native = _AudioNative()..install();
|
||||
analyzer = _Analyzer();
|
||||
@@ -162,12 +166,228 @@ void main() {
|
||||
);
|
||||
analyzer.pending?.complete(null);
|
||||
setAutoMixEnabled(false);
|
||||
setUsbBitPerfectEnabled(false);
|
||||
setAutoplayEnabled(false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
expect(native.live, isEmpty);
|
||||
expect(native.playing, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'USB preference is opt-in and persists with existing DSP preferences',
|
||||
() {
|
||||
const defaults = AppSettings();
|
||||
expect(defaults.usbBitPerfect, isFalse);
|
||||
final selected = defaults.copyWith(
|
||||
usbBitPerfect: true,
|
||||
autoMix: true,
|
||||
playbackNormalization: true,
|
||||
);
|
||||
final restored = AppSettings.fromJson(selected.toJson());
|
||||
expect(restored.usbBitPerfect, isTrue);
|
||||
expect(restored.autoMix, isTrue);
|
||||
expect(restored.playbackNormalization, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'USB mode prevents a second DSP deck even when AutoMix is enabled',
|
||||
() async {
|
||||
setAutoMixEnabled(true);
|
||||
setUsbBitPerfectEnabled(true);
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 450));
|
||||
expect(native.live, {'music-player'});
|
||||
expect(native.lastVolume('music-player'), 1);
|
||||
expect(analyzer.calls, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
group('USB playback transport', () {
|
||||
const methods = MethodChannel('com.zarz.spotiflac/usb_pcm');
|
||||
const events = MethodChannel('com.zarz.spotiflac/usb_pcm/events');
|
||||
late MusicPlaybackDeck deck;
|
||||
late List<String> usbCalls;
|
||||
late int token;
|
||||
late String? fallbackReason;
|
||||
late bool failResume;
|
||||
Completer<void>? pauseReply;
|
||||
var position = 0;
|
||||
|
||||
Future<void> event(String name, {int? withToken}) =>
|
||||
native.messenger.handlePlatformMessage(
|
||||
events.name,
|
||||
const StandardMethodCodec().encodeSuccessEnvelope({
|
||||
'event': name,
|
||||
'token': withToken ?? token,
|
||||
}),
|
||||
(_) {},
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
usbCalls = [];
|
||||
token = 0;
|
||||
fallbackReason = null;
|
||||
failResume = false;
|
||||
pauseReply = null;
|
||||
position = 0;
|
||||
native.messenger.setMockMethodCallHandler(events, (_) async => null);
|
||||
native.messenger.setMockMethodCallHandler(methods, (call) async {
|
||||
usbCalls.add(call.method);
|
||||
switch (call.method) {
|
||||
case 'prepare':
|
||||
token = (call.arguments as Map)['token'] as int;
|
||||
if (fallbackReason != null) return {'reason': fallbackReason};
|
||||
return {
|
||||
'ready': true,
|
||||
'duration': 60000,
|
||||
'device': 'Test DAC',
|
||||
'sampleRate': 96000,
|
||||
'bitDepth': 24,
|
||||
};
|
||||
case 'position':
|
||||
return position;
|
||||
case 'seek':
|
||||
position = (call.arguments as Map)['position'] as int;
|
||||
case 'resume':
|
||||
if (failResume) throw PlatformException(code: 'route_changed');
|
||||
case 'pause':
|
||||
await pauseReply?.future;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
deck = MusicPlaybackDeck(playerId: 'usb-test', nativeAvailable: true);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await deck.dispose();
|
||||
native.messenger.setMockMethodCallHandler(methods, null);
|
||||
native.messenger.setMockMethodCallHandler(events, null);
|
||||
});
|
||||
|
||||
test(
|
||||
'verified route plays, pauses and seeks without software gain or rate changes',
|
||||
() async {
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/one.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
expect(usbAudioStatus.value.reason, 'ready');
|
||||
await deck.resume();
|
||||
await deck.setVolume(0.4);
|
||||
await deck.setPlaybackRate(1.1);
|
||||
expect(native.playing, isEmpty);
|
||||
expect(usbAudioStatus.value.reason, 'active');
|
||||
expect(usbAudioStatus.value.sampleRate, 96000);
|
||||
await deck.seek(const Duration(seconds: 21));
|
||||
expect(await deck.getCurrentPosition(), const Duration(seconds: 21));
|
||||
await deck.pause();
|
||||
expect(deck.state, PlayerState.paused);
|
||||
expect(usbCalls, ['prepare', 'resume', 'seek', 'position', 'pause']);
|
||||
expect(
|
||||
native.calls.where(
|
||||
(c) =>
|
||||
c.$1 == 'usb-test' &&
|
||||
['setVolume', 'setPlaybackRate'].contains(c.$2),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('late pause reply does not pause a newly prepared source', () async {
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/one.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
await deck.resume();
|
||||
pauseReply = Completer<void>();
|
||||
final pausing = deck.pause();
|
||||
await _until(() => usbCalls.contains('pause'));
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/two.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
await deck.resume();
|
||||
pauseReply!.complete();
|
||||
await pausing;
|
||||
expect(deck.state, PlayerState.playing);
|
||||
expect(usbAudioStatus.value.reason, 'active');
|
||||
});
|
||||
|
||||
test('missing support falls back without reporting bit-perfect', () async {
|
||||
fallbackReason = 'no_usb';
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/one.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
await deck.resume();
|
||||
expect(deck.isDirect, isFalse);
|
||||
expect(native.playing, {'usb-test'});
|
||||
expect(usbAudioStatus.value.reason, 'no_usb');
|
||||
});
|
||||
|
||||
test('unverified route falls back before starting USB audio', () async {
|
||||
failResume = true;
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/one.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
await deck.resume();
|
||||
expect(deck.isDirect, isFalse);
|
||||
expect(native.sources['usb-test'], '/one.flac');
|
||||
expect(native.playing, {'usb-test'});
|
||||
expect(usbAudioStatus.value.reason, 'unsupported');
|
||||
});
|
||||
|
||||
test(
|
||||
'route loss pauses and never starts the speaker automatically',
|
||||
() async {
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/one.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
await deck.resume();
|
||||
await event('paused');
|
||||
await _until(() => deck.state == PlayerState.paused);
|
||||
expect(native.playing, isEmpty);
|
||||
expect(usbAudioStatus.value.reason, 'route_changed');
|
||||
position = 19000;
|
||||
expect(deck.needsSourceReload, isTrue);
|
||||
await expectLater(deck.resume(), throwsA(isA<PlatformException>()));
|
||||
expect(native.playing, isEmpty);
|
||||
expect(await deck.getCurrentPosition(), const Duration(seconds: 19));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'events from an old song cannot pause or complete the new song',
|
||||
() async {
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/one.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
final oldToken = token;
|
||||
await deck.setSource(
|
||||
DeviceFileSource('/two.flac'),
|
||||
preferBitPerfect: true,
|
||||
);
|
||||
await deck.resume();
|
||||
var completed = 0;
|
||||
final subscription = deck.onPlayerComplete.listen((_) => completed++);
|
||||
await event('complete', withToken: oldToken);
|
||||
await event('paused', withToken: oldToken);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(deck.state, PlayerState.playing);
|
||||
expect(completed, 0);
|
||||
await event('complete');
|
||||
await _until(() => completed == 1);
|
||||
expect(deck.state, PlayerState.completed);
|
||||
await subscription.cancel();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Autoplay fills a single-track queue and follows its visible order',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user